From f783eed4be6893711c17fc6bf4f89f6a0b08ae27 Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 20 Jun 2026 07:45:23 +0000 Subject: [PATCH 001/107] feat(critique): structured CritiqueFinding + per-model calibration projection - CritiqueFinding/CritiqueSeverity/CritiqueRole shared type (BACKLOG B6) - CritiqueOutcomeCorrelatedEvent + serialization registration (BACKLOG C-A3) - core/critique module: CriticCalibration state/reducer/projector, keyed by (modelHash, role) - schema+projection only; live reviewer-loop producer wiring deferred Co-Authored-By: Claude Opus 4.8 --- core/critique/build.gradle | 11 ++ .../critique/CriticCalibrationProjector.kt | 13 ++ .../core/critique/CriticCalibrationReducer.kt | 8 + .../DefaultCriticCalibrationReducer.kt | 22 +++ .../critique/model/CriticCalibrationState.kt | 33 ++++ .../DefaultCriticCalibrationReducerTest.kt | 156 ++++++++++++++++++ .../events/CritiqueCalibrationEvents.kt | 33 ++++ .../core/events/events/CritiqueFinding.kt | 34 ++++ .../events/serialization/Serialization.kt | 2 + ...itiqueCalibrationEventSerializationTest.kt | 33 ++++ settings.gradle | 1 + 11 files changed, 346 insertions(+) create mode 100644 core/critique/build.gradle create mode 100644 core/critique/src/main/kotlin/com/correx/core/critique/CriticCalibrationProjector.kt create mode 100644 core/critique/src/main/kotlin/com/correx/core/critique/CriticCalibrationReducer.kt create mode 100644 core/critique/src/main/kotlin/com/correx/core/critique/DefaultCriticCalibrationReducer.kt create mode 100644 core/critique/src/main/kotlin/com/correx/core/critique/model/CriticCalibrationState.kt create mode 100644 core/critique/src/test/kotlin/com/correx/core/critique/DefaultCriticCalibrationReducerTest.kt create mode 100644 core/events/src/main/kotlin/com/correx/core/events/events/CritiqueCalibrationEvents.kt create mode 100644 core/events/src/main/kotlin/com/correx/core/events/events/CritiqueFinding.kt create mode 100644 core/events/src/test/kotlin/com/correx/core/events/serialization/CritiqueCalibrationEventSerializationTest.kt diff --git a/core/critique/build.gradle b/core/critique/build.gradle new file mode 100644 index 00000000..4e8d68f9 --- /dev/null +++ b/core/critique/build.gradle @@ -0,0 +1,11 @@ +plugins { + id 'java-library' + id 'org.jetbrains.kotlin.jvm' + id 'org.jetbrains.kotlin.plugin.serialization' +} + +dependencies { + implementation(project(":core:events")) + testImplementation "org.jetbrains.kotlin:kotlin-test" +} +tasks.named("koverVerify").configure { enabled = false } diff --git a/core/critique/src/main/kotlin/com/correx/core/critique/CriticCalibrationProjector.kt b/core/critique/src/main/kotlin/com/correx/core/critique/CriticCalibrationProjector.kt new file mode 100644 index 00000000..d0919260 --- /dev/null +++ b/core/critique/src/main/kotlin/com/correx/core/critique/CriticCalibrationProjector.kt @@ -0,0 +1,13 @@ +package com.correx.core.critique + +import com.correx.core.critique.model.CriticCalibrationState +import com.correx.core.events.events.StoredEvent +import com.correx.core.sessions.projections.Projection + +class CriticCalibrationProjector( + private val reducer: CriticCalibrationReducer, +) : Projection { + override fun initial(): CriticCalibrationState = CriticCalibrationState() + override fun apply(state: CriticCalibrationState, event: StoredEvent): CriticCalibrationState = + reducer.reduce(state, event) +} diff --git a/core/critique/src/main/kotlin/com/correx/core/critique/CriticCalibrationReducer.kt b/core/critique/src/main/kotlin/com/correx/core/critique/CriticCalibrationReducer.kt new file mode 100644 index 00000000..493d6632 --- /dev/null +++ b/core/critique/src/main/kotlin/com/correx/core/critique/CriticCalibrationReducer.kt @@ -0,0 +1,8 @@ +package com.correx.core.critique + +import com.correx.core.critique.model.CriticCalibrationState +import com.correx.core.events.events.StoredEvent + +interface CriticCalibrationReducer { + fun reduce(state: CriticCalibrationState, event: StoredEvent): CriticCalibrationState +} diff --git a/core/critique/src/main/kotlin/com/correx/core/critique/DefaultCriticCalibrationReducer.kt b/core/critique/src/main/kotlin/com/correx/core/critique/DefaultCriticCalibrationReducer.kt new file mode 100644 index 00000000..3630d8a2 --- /dev/null +++ b/core/critique/src/main/kotlin/com/correx/core/critique/DefaultCriticCalibrationReducer.kt @@ -0,0 +1,22 @@ +package com.correx.core.critique + +import com.correx.core.critique.model.CalibrationKey +import com.correx.core.critique.model.CalibrationStats +import com.correx.core.critique.model.CriticCalibrationState +import com.correx.core.events.events.CritiqueOutcome +import com.correx.core.events.events.CritiqueOutcomeCorrelatedEvent +import com.correx.core.events.events.StoredEvent + +class DefaultCriticCalibrationReducer : CriticCalibrationReducer { + override fun reduce(state: CriticCalibrationState, event: StoredEvent): CriticCalibrationState { + val payload = event.payload as? CritiqueOutcomeCorrelatedEvent ?: return state + val key = CalibrationKey(payload.modelHash, payload.role) + val current = state.byKey[key] ?: CalibrationStats() + val updated = when (payload.outcome) { + CritiqueOutcome.UPHELD -> current.copy(upheld = current.upheld + 1) + CritiqueOutcome.DISMISSED -> current.copy(dismissed = current.dismissed + 1) + CritiqueOutcome.INCONCLUSIVE -> current.copy(inconclusive = current.inconclusive + 1) + } + return state.copy(byKey = state.byKey + (key to updated)) + } +} diff --git a/core/critique/src/main/kotlin/com/correx/core/critique/model/CriticCalibrationState.kt b/core/critique/src/main/kotlin/com/correx/core/critique/model/CriticCalibrationState.kt new file mode 100644 index 00000000..7b4ddf50 --- /dev/null +++ b/core/critique/src/main/kotlin/com/correx/core/critique/model/CriticCalibrationState.kt @@ -0,0 +1,33 @@ +package com.correx.core.critique.model + +import com.correx.core.events.events.CritiqueRole +import kotlinx.serialization.Serializable + +/** Composite key: critic precision is tracked per model and per role. */ +@Serializable +data class CalibrationKey(val modelHash: String, val role: CritiqueRole) + +/** + * Outcome tallies for one [CalibrationKey]. [precision] = upheld / (upheld + dismissed) — the + * share of *decisive* findings that held up — or null when no decisive findings exist yet. + * INCONCLUSIVE outcomes count toward [total] but never toward precision. + */ +@Serializable +data class CalibrationStats( + val upheld: Int = 0, + val dismissed: Int = 0, + val inconclusive: Int = 0, +) { + val total: Int get() = upheld + dismissed + inconclusive + val decisive: Int get() = upheld + dismissed + val precision: Double? get() = if (decisive == 0) null else upheld.toDouble() / decisive +} + +/** + * Replay-built view of how each (model, role) critic has performed. Rebuilt from + * [com.correx.core.events.events.CritiqueOutcomeCorrelatedEvent]s; never persisted directly. + */ +@Serializable +data class CriticCalibrationState( + val byKey: Map = emptyMap(), +) diff --git a/core/critique/src/test/kotlin/com/correx/core/critique/DefaultCriticCalibrationReducerTest.kt b/core/critique/src/test/kotlin/com/correx/core/critique/DefaultCriticCalibrationReducerTest.kt new file mode 100644 index 00000000..a5fdc2e1 --- /dev/null +++ b/core/critique/src/test/kotlin/com/correx/core/critique/DefaultCriticCalibrationReducerTest.kt @@ -0,0 +1,156 @@ +package com.correx.core.critique + +import com.correx.core.critique.model.CalibrationKey +import com.correx.core.critique.model.CriticCalibrationState +import com.correx.core.events.events.CritiqueOutcome +import com.correx.core.events.events.CritiqueOutcomeCorrelatedEvent +import com.correx.core.events.events.CritiqueRole +import com.correx.core.events.events.CritiqueSeverity +import com.correx.core.events.events.EventMetadata +import com.correx.core.events.events.EventPayload +import com.correx.core.events.events.StageCompletedEvent +import com.correx.core.events.events.StoredEvent +import com.correx.core.events.types.EventId +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.events.types.TransitionId +import kotlinx.datetime.Instant +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DefaultCriticCalibrationReducerTest { + + private val reducer = DefaultCriticCalibrationReducer() + private val projector = CriticCalibrationProjector(reducer) + private var seq = 0L + + private fun stored(payload: EventPayload): StoredEvent { + seq += 1 + return StoredEvent( + metadata = EventMetadata( + eventId = EventId("e-$seq"), + sessionId = SessionId("s1"), + timestamp = Instant.parse("2026-01-01T00:00:00Z"), + schemaVersion = 1, + causationId = null, + correlationId = null, + ), + sequence = seq, + sessionSequence = seq, + payload = payload, + ) + } + + private fun outcome( + outcome: CritiqueOutcome, + modelHash: String = "m1", + role: CritiqueRole = CritiqueRole.CODE_REVIEWER, + severity: CritiqueSeverity = CritiqueSeverity.MAJOR, + ) = stored( + CritiqueOutcomeCorrelatedEvent( + sessionId = SessionId("s1"), + findingId = "f-$seq", + role = role, + modelHash = modelHash, + severity = severity, + outcome = outcome, + ), + ) + + private fun fold(events: List): CriticCalibrationState = + events.fold(projector.initial(), projector::apply) + + @Test + fun `initial state is empty`() { + assertTrue(projector.initial().byKey.isEmpty()) + } + + @Test + fun `single UPHELD increments upheld and yields precision 1`() { + val state = fold(listOf(outcome(CritiqueOutcome.UPHELD))) + val stats = state.byKey.getValue(CalibrationKey("m1", CritiqueRole.CODE_REVIEWER)) + assertEquals(1, stats.upheld) + assertEquals(0, stats.dismissed) + assertEquals(1, stats.total) + assertEquals(1.0, stats.precision) + } + + @Test + fun `single DISMISSED increments dismissed and yields precision 0`() { + val state = fold(listOf(outcome(CritiqueOutcome.DISMISSED))) + val stats = state.byKey.getValue(CalibrationKey("m1", CritiqueRole.CODE_REVIEWER)) + assertEquals(1, stats.dismissed) + assertEquals(0.0, stats.precision) + } + + @Test + fun `INCONCLUSIVE counts toward total but not precision`() { + val state = fold(listOf(outcome(CritiqueOutcome.INCONCLUSIVE))) + val stats = state.byKey.getValue(CalibrationKey("m1", CritiqueRole.CODE_REVIEWER)) + assertEquals(1, stats.inconclusive) + assertEquals(1, stats.total) + assertEquals(0, stats.decisive) + assertNull(stats.precision, "precision is undefined with no decisive findings") + } + + @Test + fun `distinct model-role keys are tracked independently`() { + val state = fold( + listOf( + outcome(CritiqueOutcome.UPHELD, modelHash = "m1", role = CritiqueRole.CODE_REVIEWER), + outcome(CritiqueOutcome.DISMISSED, modelHash = "m2", role = CritiqueRole.CODE_REVIEWER), + outcome(CritiqueOutcome.UPHELD, modelHash = "m1", role = CritiqueRole.PLAN_CRITIC), + ), + ) + assertEquals(3, state.byKey.size) + assertEquals(1.0, state.byKey.getValue(CalibrationKey("m1", CritiqueRole.CODE_REVIEWER)).precision) + assertEquals(0.0, state.byKey.getValue(CalibrationKey("m2", CritiqueRole.CODE_REVIEWER)).precision) + assertEquals(1.0, state.byKey.getValue(CalibrationKey("m1", CritiqueRole.PLAN_CRITIC)).precision) + } + + @Test + fun `precision is computed over a mix of outcomes for one key`() { + val state = fold( + listOf( + outcome(CritiqueOutcome.UPHELD), + outcome(CritiqueOutcome.UPHELD), + outcome(CritiqueOutcome.UPHELD), + outcome(CritiqueOutcome.DISMISSED), + outcome(CritiqueOutcome.INCONCLUSIVE), + ), + ) + val stats = state.byKey.getValue(CalibrationKey("m1", CritiqueRole.CODE_REVIEWER)) + assertEquals(3, stats.upheld) + assertEquals(1, stats.dismissed) + assertEquals(1, stats.inconclusive) + assertEquals(5, stats.total) + assertEquals(0.75, stats.precision) // 3 / (3 + 1) + } + + @Test + fun `unrelated events pass through unchanged`() { + val state = fold( + listOf( + stored( + StageCompletedEvent( + sessionId = SessionId("s1"), + stageId = StageId("plan"), + transitionId = TransitionId("t1"), + ), + ), + outcome(CritiqueOutcome.UPHELD), + stored( + StageCompletedEvent( + sessionId = SessionId("s1"), + stageId = StageId("review"), + transitionId = TransitionId("t2"), + ), + ), + ), + ) + assertEquals(1, state.byKey.size) + assertEquals(1, state.byKey.getValue(CalibrationKey("m1", CritiqueRole.CODE_REVIEWER)).upheld) + } +} diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/CritiqueCalibrationEvents.kt b/core/events/src/main/kotlin/com/correx/core/events/events/CritiqueCalibrationEvents.kt new file mode 100644 index 00000000..908f202e --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/events/CritiqueCalibrationEvents.kt @@ -0,0 +1,33 @@ +package com.correx.core.events.events + +import com.correx.core.events.types.SessionId +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * How a prior [CritiqueFinding] actually turned out once the surrounding loop resolved. + * UPHELD = the finding was confirmed/actioned; DISMISSED = rejected as a false positive; + * INCONCLUSIVE = no signal either way. + */ +@Serializable +enum class CritiqueOutcome { UPHELD, DISMISSED, INCONCLUSIVE } + +/** + * Correlates one prior [CritiqueFinding] (by [findingId]) with how it actually turned out, so a + * critic's precision can be tracked per model and per role over time (the calibration projection + * in `:core:critique`). [modelHash] identifies the model that produced the finding; [severity] + * is carried so calibration can be sliced by severity band without re-reading the finding. + * + * This is the recording half only — who decides UPHELD/DISMISSED lives in the reviewer-loop + * runtime and is wired separately. + */ +@Serializable +@SerialName("CritiqueOutcomeCorrelated") +data class CritiqueOutcomeCorrelatedEvent( + val sessionId: SessionId, + val findingId: String, + val role: CritiqueRole, + val modelHash: String, + val severity: CritiqueSeverity, + val outcome: CritiqueOutcome, +) : EventPayload diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/CritiqueFinding.kt b/core/events/src/main/kotlin/com/correx/core/events/events/CritiqueFinding.kt new file mode 100644 index 00000000..1a4e5839 --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/events/CritiqueFinding.kt @@ -0,0 +1,34 @@ +package com.correx.core.events.events + +import kotlinx.serialization.Serializable + +/** Which critic role produced a [CritiqueFinding]. */ +@Serializable +enum class CritiqueRole { PLAN_CRITIC, CODE_REVIEWER } + +/** Severity of a [CritiqueFinding], highest-impact first. */ +@Serializable +enum class CritiqueSeverity { BLOCKER, MAJOR, MINOR, NIT } + +/** + * One structured finding emitted by a critic (plan critic) or reviewer (code reviewer), + * replacing the free-text verdict prose those roles produced before. Shared by both roles so + * findings can be counted, ranked, and calibrated uniformly. + * + * [id] is a stable identifier for the finding, used to correlate it with its eventual outcome + * (see [CritiqueOutcomeCorrelatedEvent]). [category] is a free-form classification + * (e.g. "correctness", "missing_requirement", "style"). [location] optionally points at a + * `file:line` or an artifact/stage reference the finding concerns. + * + * Distinct from `core.validation.ValidationIssue`, which models *structural* validation + * (graph/schema) rather than a critic's judgement. + */ +@Serializable +data class CritiqueFinding( + val id: String, + val role: CritiqueRole, + val severity: CritiqueSeverity, + val category: String, + val message: String, + val location: String? = null, +) diff --git a/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt b/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt index cbf6cf76..20d6ed08 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt @@ -13,6 +13,7 @@ import com.correx.core.events.events.ChatSessionStartedEvent import com.correx.core.events.events.ClarificationAnsweredEvent import com.correx.core.events.events.ClarificationRequestedEvent import com.correx.core.events.events.ChatTurnEvent +import com.correx.core.events.events.CritiqueOutcomeCorrelatedEvent import com.correx.core.events.events.RouterNarrationEvent import com.correx.core.events.events.OperatorProfileBoundEvent import com.correx.core.events.events.ProjectProfileBoundEvent @@ -127,6 +128,7 @@ val eventModule = SerializersModule { subclass(WorkflowProposedEvent::class) subclass(IdeaCapturedEvent::class) subclass(IdeaDiscardedEvent::class) + subclass(CritiqueOutcomeCorrelatedEvent::class) } } diff --git a/core/events/src/test/kotlin/com/correx/core/events/serialization/CritiqueCalibrationEventSerializationTest.kt b/core/events/src/test/kotlin/com/correx/core/events/serialization/CritiqueCalibrationEventSerializationTest.kt new file mode 100644 index 00000000..7b1bcc98 --- /dev/null +++ b/core/events/src/test/kotlin/com/correx/core/events/serialization/CritiqueCalibrationEventSerializationTest.kt @@ -0,0 +1,33 @@ +package com.correx.core.events.serialization + +import com.correx.core.events.events.CritiqueOutcome +import com.correx.core.events.events.CritiqueOutcomeCorrelatedEvent +import com.correx.core.events.events.CritiqueRole +import com.correx.core.events.events.CritiqueSeverity +import com.correx.core.events.events.EventPayload +import com.correx.core.events.types.SessionId +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class CritiqueCalibrationEventSerializationTest { + + @Test + fun `CritiqueOutcomeCorrelatedEvent round-trips as polymorphic EventPayload`() { + val sample: EventPayload = CritiqueOutcomeCorrelatedEvent( + sessionId = SessionId("s"), + findingId = "f-1", + role = CritiqueRole.CODE_REVIEWER, + modelHash = "sha256:abc", + severity = CritiqueSeverity.MAJOR, + outcome = CritiqueOutcome.UPHELD, + ) + val encoded = eventJson.encodeToString(EventPayload.serializer(), sample) + assertTrue( + encoded.contains("\"type\":\"CritiqueOutcomeCorrelated\""), + "SerialName must be present: $encoded", + ) + val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded) + assertEquals(sample, decoded) + } +} diff --git a/settings.gradle b/settings.gradle index f10a760e..b98ee139 100644 --- a/settings.gradle +++ b/settings.gradle @@ -28,6 +28,7 @@ include ':core:sessions' include ':core:config' include ':core:risk' include ':core:journal' +include ':core:critique' include ':infrastructure:persistence' include ':infrastructure:inference' From 784accb08d5a1fa73ac5dbfe6beb2d53c0062172 Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 20 Jun 2026 08:08:49 +0000 Subject: [PATCH 002/107] fix(memory): trailing-delimiter turnId namespace + newline fingerprint - repomap: tag prefix match requires trailing ':' so /repo can't match /repo2 (L3RepoKnowledgeRetriever filter + ProjectMemoryService write; the existing existsByTurnIdPrefix check already included the delimiter) - WorkspaceStateProbe fingerprint joins entries with newline to match docstring - regression test for /repo vs /repo2 non-collision Co-Authored-By: Claude Opus 4.8 --- .../server/memory/L3RepoKnowledgeRetriever.kt | 2 +- .../server/memory/ProjectMemoryService.kt | 2 +- .../apps/server/memory/WorkspaceStateProbe.kt | 2 +- .../memory/ProjectMemoryServiceReuseTest.kt | 33 +++++++++++++++++++ 4 files changed, 36 insertions(+), 3 deletions(-) diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/memory/L3RepoKnowledgeRetriever.kt b/apps/server/src/main/kotlin/com/correx/apps/server/memory/L3RepoKnowledgeRetriever.kt index 5aeef84c..32dae478 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/memory/L3RepoKnowledgeRetriever.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/memory/L3RepoKnowledgeRetriever.kt @@ -17,7 +17,7 @@ class L3RepoKnowledgeRetriever( override suspend fun retrieve(sessionId: SessionId, query: String, k: Int): List { val vector = embedder.embed(query) return l3MemoryStore.query(L3Query(vector = vector, k = k * RETRIEVAL_OVERSAMPLE_FACTOR)) - .filter { it.entry.turnId.startsWith("repomap:$repoRoot") } + .filter { it.entry.turnId.startsWith("repomap:$repoRoot:") } .take(k) .map { RepoKnowledgeHit(path = it.entry.text.substringBefore(":"), text = it.entry.text, score = it.score) } } diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/memory/ProjectMemoryService.kt b/apps/server/src/main/kotlin/com/correx/apps/server/memory/ProjectMemoryService.kt index faaf32a1..f6a3bf99 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/memory/ProjectMemoryService.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/memory/ProjectMemoryService.kt @@ -85,7 +85,7 @@ class ProjectMemoryService( ), ), ) - val tag = if (stateKey != null) "repomap:$repoRoot:$stateKey" else "repomap:$repoRoot" + val tag = if (stateKey != null) "repomap:$repoRoot:$stateKey" else "repomap:$repoRoot:" entries.forEach { entry -> runCatching { val text = entry.path + if (entry.symbols.isEmpty()) "" else ": ${entry.symbols.joinToString(", ")}" diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/memory/WorkspaceStateProbe.kt b/apps/server/src/main/kotlin/com/correx/apps/server/memory/WorkspaceStateProbe.kt index 0ff8e2e9..a0b1ed69 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/memory/WorkspaceStateProbe.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/memory/WorkspaceStateProbe.kt @@ -109,7 +109,7 @@ class RealWorkspaceStateProbe : WorkspaceStateProbe { .toList() } val digest = MessageDigest.getInstance("SHA-256") - lines.forEach { digest.update(it.toByteArray()) } + digest.update(lines.joinToString("\n").toByteArray()) return digest.digest().joinToString("") { "%02x".format(it) }.take(FINGERPRINT_HEX_LENGTH) } } diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/memory/ProjectMemoryServiceReuseTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/memory/ProjectMemoryServiceReuseTest.kt index e5345b91..ae3e6652 100644 --- a/apps/server/src/test/kotlin/com/correx/apps/server/memory/ProjectMemoryServiceReuseTest.kt +++ b/apps/server/src/test/kotlin/com/correx/apps/server/memory/ProjectMemoryServiceReuseTest.kt @@ -12,6 +12,7 @@ import com.correx.core.events.types.SessionId import com.correx.infrastructure.persistence.InMemoryEventStore import kotlinx.coroutines.runBlocking 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 java.nio.file.Path @@ -121,4 +122,36 @@ class ProjectMemoryServiceReuseTest { "entries should be embedded with stateKey tag", ) } + + @Test + fun `repoRoot prefix does not collide across roots — repo vs repo2`(): Unit = runBlocking { + val es = InMemoryEventStore() + val l3 = InMemoryL3MemoryStore() + val probe = FakeWorkspaceStateProbe(WorkspaceState("git:hash1", "git", "main", false)) + val embedder = ConstantEmbedderReuse() + + // Index /repo and /repo2 into the same shared L3 store with the same stateKey. + // The trailing-':' delimiter on the repomap: tag must keep their entries from bleeding + // across roots: "/repo" must NOT match tags written for "/repo2". + val repoEntries = listOf(RepoMapEntry(path = "src/Repo.kt", score = 1.0, symbols = listOf("RepoClass"))) + val repo2Entries = listOf(RepoMapEntry(path = "src/Repo2.kt", score = 1.0, symbols = listOf("Repo2Class"))) + + service(es, l3, CountingIndexer(repoEntries), probe, root = "/repo") + .indexAndRecord(SessionId("session-collide-repo"), "/repo") + service(es, l3, CountingIndexer(repo2Entries), probe, root = "/repo2") + .indexAndRecord(SessionId("session-collide-repo2"), "/repo2") + + // existsByTurnIdPrefix with the delimiter must not match the other root. + assertTrue(l3.existsByTurnIdPrefix("repomap:/repo:"), "tag for /repo should exist") + assertTrue(l3.existsByTurnIdPrefix("repomap:/repo2:"), "tag for /repo2 should exist") + + // The retriever scoped to /repo must return only /repo's entry, never /repo2's. + val hits = L3RepoKnowledgeRetriever(embedder = embedder, l3MemoryStore = l3, repoRoot = "/repo") + .retrieve(SessionId("session-collide-query"), "anything", k = 10) + + assertTrue(hits.isNotEmpty(), "retriever for /repo should find /repo entries") + val text = hits.joinToString(" ") { it.text } + assertTrue(text.contains("src/Repo.kt"), "should contain /repo entry; got: $text") + assertFalse(text.contains("src/Repo2.kt"), "must NOT contain /repo2 entry (prefix collision); got: $text") + } } From 266bbf0269d80f0f2670bc06fd37dcce9de722da Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 20 Jun 2026 08:39:26 +0000 Subject: [PATCH 003/107] feat(health): EventStore latency + llama-server liveness probes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EventStoreLatencyProbe: times a side-effect-free lastGlobalSequence read, wired into HealthMonitor; HealthConfig.eventStoreWarnLatencyMs (BACKLOG A §4) - LlamaServerHealthProbe: liveness + tokens/sec degradation trend, injectable client (HttpLlamaLivenessClient over Ktor); deterministic unit tests - llama probe left unregistered (TODO) — HttpClient not in monitor scope yet Co-Authored-By: Claude Opus 4.8 --- .../kotlin/com/correx/apps/server/Main.kt | 7 ++ .../server/health/EventStoreLatencyProbe.kt | 65 ++++++++++ .../server/health/LlamaServerHealthProbe.kt | 113 ++++++++++++++++++ .../health/EventStoreLatencyProbeTest.kt | 62 ++++++++++ .../health/LlamaServerHealthProbeTest.kt | 100 ++++++++++++++++ .../com/correx/core/config/CorrexConfig.kt | 5 +- 6 files changed, 351 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/main/kotlin/com/correx/apps/server/health/EventStoreLatencyProbe.kt create mode 100644 apps/server/src/main/kotlin/com/correx/apps/server/health/LlamaServerHealthProbe.kt create mode 100644 apps/server/src/test/kotlin/com/correx/apps/server/health/EventStoreLatencyProbeTest.kt create mode 100644 apps/server/src/test/kotlin/com/correx/apps/server/health/LlamaServerHealthProbeTest.kt diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt index 159ac1da..f7c06d79 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt @@ -436,6 +436,13 @@ fun main() { warnBytes = hc.diskWarnBytes, warnGrowthBytesPerMin = hc.diskWarnGrowthBytesPerMin, ), + com.correx.apps.server.health.EventStoreLatencyProbe.forStore( + eventStore, + hc.eventStoreWarnLatencyMs, + ), + // TODO(wiring): register LlamaServerHealthProbe once an HttpClient + tokens/sec telemetry are + // exposed to the health-monitor scope (currently only built inside + // InfrastructureModule.createModelManager). ), intervalMs = hc.intervalMs, initialStatuses = seeded, diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/health/EventStoreLatencyProbe.kt b/apps/server/src/main/kotlin/com/correx/apps/server/health/EventStoreLatencyProbe.kt new file mode 100644 index 00000000..bd57c20c --- /dev/null +++ b/apps/server/src/main/kotlin/com/correx/apps/server/health/EventStoreLatencyProbe.kt @@ -0,0 +1,65 @@ +package com.correx.apps.server.health + +import com.correx.core.events.events.HealthSubject +import com.correx.core.events.stores.EventStore + +private const val NANOS_PER_MILLI = 1_000_000L + +/** + * Watches event-log append/fsync latency (observability-spec §4: "the organ that must never lie"). + * Each tick times one durable, side-effect-free store probe and reports DEGRADED when it exceeds + * [warnLatencyMs] — a slow store means the append path (fsync) is contended or the disk is failing. + * + * The timed operation is injected so the probe never itself appends an event (that would both spam + * the health log and break replay determinism). [forStore] wires it in production to + * [EventStore.lastGlobalSequence], a durable read that rides the same connection an append's fsync + * uses. The latency reading is a live environment observation; it never affects replay (Invariant #8). + */ +class EventStoreLatencyProbe( + private val warnLatencyMs: Long, + private val nanoTime: () -> Long = System::nanoTime, + private val measure: suspend () -> Unit, +) : HealthProbe { + + override val subject: HealthSubject = HealthSubject.EVENT_STORE + + override suspend fun observe(): HealthObservation { + val start = nanoTime() + val failure = runCatching { measure() }.exceptionOrNull() + val elapsedMs = (nanoTime() - start) / NANOS_PER_MILLI + + return when { + failure != null -> degraded( + value = elapsedMs, + detail = "event store probe failed: ${failure.message ?: failure::class.simpleName}", + ) + elapsedMs >= warnLatencyMs -> degraded( + value = elapsedMs, + detail = "event store probe took ${elapsedMs}ms (warn ≥ ${warnLatencyMs}ms)", + ) + else -> HealthObservation( + subject = subject, + status = HealthStatus.HEALTHY, + metric = "latency_ms", + observedValue = elapsedMs, + threshold = warnLatencyMs, + detail = "event store probe took ${elapsedMs}ms", + ) + } + } + + private fun degraded(value: Long, detail: String) = HealthObservation( + subject = subject, + status = HealthStatus.DEGRADED, + metric = "latency_ms", + observedValue = value, + threshold = warnLatencyMs, + detail = detail, + ) + + companion object { + /** Production wiring: time a durable, side-effect-free read on [store]. */ + fun forStore(store: EventStore, warnLatencyMs: Long): EventStoreLatencyProbe = + EventStoreLatencyProbe(warnLatencyMs = warnLatencyMs) { store.lastGlobalSequence() } + } +} diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/health/LlamaServerHealthProbe.kt b/apps/server/src/main/kotlin/com/correx/apps/server/health/LlamaServerHealthProbe.kt new file mode 100644 index 00000000..044b18dd --- /dev/null +++ b/apps/server/src/main/kotlin/com/correx/apps/server/health/LlamaServerHealthProbe.kt @@ -0,0 +1,113 @@ +package com.correx.apps.server.health + +import com.correx.core.events.events.HealthSubject +import io.ktor.client.HttpClient +import io.ktor.client.request.get +import io.ktor.http.isSuccess + +private const val DEFAULT_DEGRADE_FRACTION = 0.5 +private const val DEFAULT_BASELINE_SAMPLES = 5 +private const val LIVE = 1L +private const val DEAD = 0L + +/** One liveness reading of the llama server: reachable, plus optional throughput telemetry. */ +data class LivenessResult(val reachable: Boolean, val tokensPerSecond: Double? = null) + +/** Pings the local inference process; implementations never throw (failures map to unreachable). */ +interface LlamaLivenessClient { + suspend fun ping(): LivenessResult +} + +/** + * Ktor-backed liveness client: a `GET $baseUrl/health` whose 2xx status is the only signal. + * Tokens/sec telemetry is fed separately (the health endpoint reports reachability only), so this + * client never populates [LivenessResult.tokensPerSecond]. Any transport exception is swallowed + * into an unreachable result so the probe sees a clean signal. + */ +class HttpLlamaLivenessClient( + private val httpClient: HttpClient, + private val baseUrl: String, +) : LlamaLivenessClient { + override suspend fun ping(): LivenessResult = runCatching { + val response = httpClient.get("$baseUrl/health") + LivenessResult(reachable = response.status.isSuccess()) + }.getOrElse { LivenessResult(reachable = false) } +} + +/** + * Watches the local inference process (observability-spec §4: "liveness + tokens/sec degradation"). + * Two failure modes: the server stops answering (DEGRADED on the `liveness` metric), or it stays + * up but throughput collapses — KV-cache pressure or thermal throttle. The latter is judged against + * a rolling PEAK of the last [baselineSamples] readings: when current tps falls below + * [degradeFraction] of that peak, the subject degrades on the `tokens_per_sec` metric. + * + * The rolling window is per-run live state and never affects replay (Invariant #8): the monitor's + * recorded edge events are the sole replay input, so a fresh process starting with an empty window + * reconstructs identical history from the log. + */ +class LlamaServerHealthProbe( + private val client: LlamaLivenessClient, + private val degradeFraction: Double = DEFAULT_DEGRADE_FRACTION, + private val baselineSamples: Int = DEFAULT_BASELINE_SAMPLES, +) : HealthProbe { + + override val subject: HealthSubject = HealthSubject.LLAMA_SERVER + + private val recent = ArrayDeque() + + override suspend fun observe(): HealthObservation { + val result = runCatching { client.ping() }.getOrElse { LivenessResult(reachable = false) } + + if (!result.reachable) { + return HealthObservation( + subject = subject, + status = HealthStatus.DEGRADED, + metric = "liveness", + observedValue = DEAD, + threshold = LIVE, + detail = "llama server unreachable", + ) + } + + val tps = result.tokensPerSecond + ?: return HealthObservation( + subject = subject, + status = HealthStatus.HEALTHY, + metric = "liveness", + observedValue = LIVE, + threshold = LIVE, + detail = "llama server reachable", + ) + + return throughputObservation(tps) + } + + private fun throughputObservation(tps: Double): HealthObservation { + recent.addLast(tps) + while (recent.size > baselineSamples) recent.removeFirst() + + val baseline = recent.maxOrNull() ?: tps + val floor = baseline * degradeFraction + + return if (baseline > 0.0 && tps < floor) { + HealthObservation( + subject = subject, + status = HealthStatus.DEGRADED, + metric = "tokens_per_sec", + observedValue = tps.toLong(), + threshold = floor.toLong(), + detail = "throughput ${"%.1f".format(tps)} tok/s below ${"%.0f".format(degradeFraction * 100)}% " + + "of peak ${"%.1f".format(baseline)} tok/s", + ) + } else { + HealthObservation( + subject = subject, + status = HealthStatus.HEALTHY, + metric = "tokens_per_sec", + observedValue = tps.toLong(), + threshold = floor.toLong(), + detail = "throughput ${"%.1f".format(tps)} tok/s (peak ${"%.1f".format(baseline)} tok/s)", + ) + } + } +} diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/health/EventStoreLatencyProbeTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/health/EventStoreLatencyProbeTest.kt new file mode 100644 index 00000000..c1ab0fa3 --- /dev/null +++ b/apps/server/src/test/kotlin/com/correx/apps/server/health/EventStoreLatencyProbeTest.kt @@ -0,0 +1,62 @@ +package com.correx.apps.server.health + +import com.correx.infrastructure.persistence.InMemoryEventStore +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class EventStoreLatencyProbeTest { + + /** Hand-advanced nano source: the [measure] lambda decides how much wall-clock the probe "saw". */ + private class FakeNanos { + var now: Long = 0L + fun read(): Long = now + fun advanceMs(ms: Long) { + now += ms * 1_000_000L + } + } + + @Test + fun `healthy when latency under threshold`(): Unit = runBlocking { + val nanos = FakeNanos() + val probe = EventStoreLatencyProbe( + warnLatencyMs = 50, + nanoTime = nanos::read, + ) { nanos.advanceMs(10) } + val obs = probe.observe() + assertEquals(HealthStatus.HEALTHY, obs.status) + assertEquals("latency_ms", obs.metric) + assertEquals(10, obs.observedValue) + } + + @Test + fun `degraded when latency crosses threshold`(): Unit = runBlocking { + val nanos = FakeNanos() + val probe = EventStoreLatencyProbe( + warnLatencyMs = 50, + nanoTime = nanos::read, + ) { nanos.advanceMs(120) } + val obs = probe.observe() + assertEquals(HealthStatus.DEGRADED, obs.status) + assertEquals("latency_ms", obs.metric) + assertEquals(120, obs.observedValue) + assertEquals(50, obs.threshold) + } + + @Test + fun `degraded when the timed read throws`(): Unit = runBlocking { + val probe = EventStoreLatencyProbe(warnLatencyMs = 50) { error("store down") } + val obs = probe.observe() + assertEquals(HealthStatus.DEGRADED, obs.status) + assertEquals("latency_ms", obs.metric) + assertTrue(obs.detail.contains("store down"), "detail should name the failure: ${obs.detail}") + } + + @Test + fun `forStore reads a real store and stays healthy under a generous watermark`(): Unit = runBlocking { + val store = InMemoryEventStore() + val probe = EventStoreLatencyProbe.forStore(store, warnLatencyMs = Long.MAX_VALUE) + assertEquals(HealthStatus.HEALTHY, probe.observe().status) + } +} diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/health/LlamaServerHealthProbeTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/health/LlamaServerHealthProbeTest.kt new file mode 100644 index 00000000..ab62f3c4 --- /dev/null +++ b/apps/server/src/test/kotlin/com/correx/apps/server/health/LlamaServerHealthProbeTest.kt @@ -0,0 +1,100 @@ +package com.correx.apps.server.health + +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.http.HttpStatusCode +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class LlamaServerHealthProbeTest { + + /** Replays a queued script of liveness readings; [error] is thrown when the queue runs dry on demand. */ + private class ScriptedClient(results: List, private val throwOnPing: Boolean = false) : + LlamaLivenessClient { + private val queue = ArrayDeque(results) + override suspend fun ping(): LivenessResult { + if (throwOnPing) error("ping boom") + return queue.removeFirst() + } + } + + private fun reachable(tps: Double? = null) = LivenessResult(reachable = true, tokensPerSecond = tps) + + @Test + fun `reachable with stable throughput stays healthy`(): Unit = runBlocking { + val probe = LlamaServerHealthProbe(ScriptedClient(listOf(reachable(100.0), reachable(100.0), reachable(100.0)))) + assertEquals(HealthStatus.HEALTHY, probe.observe().status) + assertEquals(HealthStatus.HEALTHY, probe.observe().status) + val obs = probe.observe() + assertEquals(HealthStatus.HEALTHY, obs.status) + assertEquals("tokens_per_sec", obs.metric) + } + + @Test + fun `unreachable degrades on the liveness metric`(): Unit = runBlocking { + val probe = LlamaServerHealthProbe(ScriptedClient(listOf(LivenessResult(reachable = false)))) + val obs = probe.observe() + assertEquals(HealthStatus.DEGRADED, obs.status) + assertEquals("liveness", obs.metric) + assertEquals(0, obs.observedValue) + } + + @Test + fun `reachable without telemetry stays healthy on liveness`(): Unit = runBlocking { + val probe = LlamaServerHealthProbe(ScriptedClient(listOf(reachable()))) + val obs = probe.observe() + assertEquals(HealthStatus.HEALTHY, obs.status) + assertEquals("liveness", obs.metric) + assertEquals(1, obs.observedValue) + } + + @Test + fun `throughput collapse below half of peak degrades, then recovers`(): Unit = runBlocking { + val probe = LlamaServerHealthProbe( + ScriptedClient(listOf(reachable(100.0), reachable(100.0), reachable(30.0), reachable(100.0))), + ) + assertEquals(HealthStatus.HEALTHY, probe.observe().status) + assertEquals(HealthStatus.HEALTHY, probe.observe().status) + + val dropped = probe.observe() + assertEquals(HealthStatus.DEGRADED, dropped.status) + assertEquals("tokens_per_sec", dropped.metric) + assertEquals(30, dropped.observedValue) + + val recovered = probe.observe() + assertEquals(HealthStatus.HEALTHY, recovered.status) + assertEquals("tokens_per_sec", recovered.metric) + } + + @Test + fun `ping that throws is treated as unreachable`(): Unit = runBlocking { + val probe = LlamaServerHealthProbe(ScriptedClient(emptyList(), throwOnPing = true)) + val obs = probe.observe() + assertEquals(HealthStatus.DEGRADED, obs.status) + assertEquals("liveness", obs.metric) + } + + private fun clientFor(status: HttpStatusCode) = + HttpLlamaLivenessClient(HttpClient(MockEngine { respond(content = "", status = status) }), "http://llama") + + @Test + fun `http client reports reachable on 200`(): Unit = runBlocking { + assertEquals(true, clientFor(HttpStatusCode.OK).ping().reachable) + } + + @Test + fun `http client reports unreachable on 503`(): Unit = runBlocking { + assertEquals(false, clientFor(HttpStatusCode.ServiceUnavailable).ping().reachable) + } + + @Test + fun `http client reports unreachable when transport throws`(): Unit = runBlocking { + val client = HttpLlamaLivenessClient( + HttpClient(MockEngine { error("connection refused") }), + "http://llama", + ) + assertEquals(false, client.ping().reachable) + } +} diff --git a/core/config/src/main/kotlin/com/correx/core/config/CorrexConfig.kt b/core/config/src/main/kotlin/com/correx/core/config/CorrexConfig.kt index 73e36a9c..6d141a31 100644 --- a/core/config/src/main/kotlin/com/correx/core/config/CorrexConfig.kt +++ b/core/config/src/main/kotlin/com/correx/core/config/CorrexConfig.kt @@ -23,7 +23,9 @@ data class CorrexConfig( * Continuous health watch (observability-spec §4). When [enabled], a background monitor polls * the on-disk footprint every [intervalMs] and records a degraded/restored event on the edge. * [diskWarnBytes] is the absolute event-log + CAS size that trips a warning; [diskWarnGrowthBytesPerMin] - * is the growth rate that warns log compaction is becoming non-theoretical. Defaults suit a homelab. + * is the growth rate that warns log compaction is becoming non-theoretical. [eventStoreWarnLatencyMs] + * is the event-store latency watermark: a single side-effect-free store read slower than this trips a + * warning that the append/fsync path is contended or the disk is failing. Defaults suit a homelab. */ @Serializable data class HealthConfig( @@ -31,6 +33,7 @@ data class HealthConfig( val intervalMs: Long = 30_000, val diskWarnBytes: Long = 5_000_000_000, val diskWarnGrowthBytesPerMin: Long = 50_000_000, + val eventStoreWarnLatencyMs: Long = 250, ) /** From f715ffa540bacd6f8cada99570d664f25b988b26 Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 20 Jun 2026 09:32:02 +0000 Subject: [PATCH 004/107] feat(repomap): index C#/Godot-Mono (.cs) type symbols Conservative type-declaration pattern (class/interface/struct/enum/record); no method capture to avoid false positives. (BACKLOG I) Co-Authored-By: Claude Opus 4.8 --- .../apps/server/memory/RepoMapIndexer.kt | 6 ++++ .../apps/server/memory/RepoMapIndexerTest.kt | 36 +++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/memory/RepoMapIndexer.kt b/apps/server/src/main/kotlin/com/correx/apps/server/memory/RepoMapIndexer.kt index 61ac841f..a85a9f99 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/memory/RepoMapIndexer.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/memory/RepoMapIndexer.kt @@ -84,6 +84,12 @@ class RepoMapIndexer( "ts" to Regex("""(?m)^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|class|interface|type|enum)\s+(?[A-Za-z_$][A-Za-z0-9_$]*)"""), // GDScript: column-0 declarations only, so indented inner-class members never match. "gd" to Regex("""(?m)^(?:static\s+)?(?:func|class_name|class|signal|enum)\s+(?[A-Za-z_][A-Za-z0-9_]*)"""), + // C# / Godot Mono: top-level type declarations with leading modifiers, kept conservative + // (no method capture — return-type heuristics there are noisy and risk false positives). + "cs" to Regex( + """(?m)^\s*(?:public |private |protected |internal |static |sealed |abstract |""" + + """partial )*(?:class|interface|struct|enum|record)\s+(?[A-Za-z_][A-Za-z0-9_]*)""", + ), // Markdown: h1–h3 headings as "symbols" — deeper levels are noise relative to retrieval value. "md" to Regex("""(?m)^#{1,3}\s+(?.+?)\s*$"""), ) diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/memory/RepoMapIndexerTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/memory/RepoMapIndexerTest.kt index f609b72b..9d3fd7a8 100644 --- a/apps/server/src/test/kotlin/com/correx/apps/server/memory/RepoMapIndexerTest.kt +++ b/apps/server/src/test/kotlin/com/correx/apps/server/memory/RepoMapIndexerTest.kt @@ -75,6 +75,42 @@ class RepoMapIndexerTest { assertFalse(entry.symbols.contains("help"), "indented inner method must not be matched") } + @Test + fun `extracts C# (Godot Mono) top-level type symbols`(@TempDir root: Path) { + root.resolve("Player.cs").writeText( + """ + using Godot; + + namespace Game; + + public sealed partial class Player : CharacterBody2D + { + private int _health = 100; + + public void TakeDamage(int amount) + { + _health -= amount; + } + } + + internal interface IDamageable + { + void TakeDamage(int amount); + } + + public enum State { Idle, Running } + """.trimIndent(), + ) + + val entry = RepoMapIndexer().index(root).single() + assertEquals("Player.cs", entry.path) + assertTrue( + entry.symbols.containsAll(listOf("Player", "IDamageable", "State")), + "missing symbols in: ${entry.symbols}", + ) + assertFalse(entry.symbols.contains("TakeDamage"), "method/field bodies must not be matched") + } + @Test fun `extracts markdown headings as symbols`(@TempDir root: Path) { root.resolve("design.md").writeText( From 0e251d6083269772400587fa67081bbc40cf9aa7 Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 20 Jun 2026 10:05:44 +0000 Subject: [PATCH 005/107] feat(validation): structural .tscn/.tres scene validator Standalone SceneValidator(content) -> ValidationReport: header well-formedness, ExtResource/SubResource id resolution, node parent-path integrity. Not wired into any pipeline (validation layer only; scene editing is a later epic). (BACKLOG I) Co-Authored-By: Claude Opus 4.8 --- .../core/validation/scene/SceneValidator.kt | 230 ++++++++++++++++++ .../validation/scene/SceneValidatorTest.kt | 156 ++++++++++++ 2 files changed, 386 insertions(+) create mode 100644 core/validation/src/main/kotlin/com/correx/core/validation/scene/SceneValidator.kt create mode 100644 core/validation/src/test/kotlin/com/correx/core/validation/scene/SceneValidatorTest.kt diff --git a/core/validation/src/main/kotlin/com/correx/core/validation/scene/SceneValidator.kt b/core/validation/src/main/kotlin/com/correx/core/validation/scene/SceneValidator.kt new file mode 100644 index 00000000..b835e715 --- /dev/null +++ b/core/validation/src/main/kotlin/com/correx/core/validation/scene/SceneValidator.kt @@ -0,0 +1,230 @@ +package com.correx.core.validation.scene + +import com.correx.core.validation.model.ValidationIssue +import com.correx.core.validation.model.ValidationReport +import com.correx.core.validation.model.ValidationSection +import com.correx.core.validation.model.ValidationSeverity + +/** + * Standalone structural validator for Godot `.tscn` / `.tres` text (an INI-like format). + * + * Pure logic: it takes the file content as a [String] and returns a [ValidationReport]. It does + * NOT touch the filesystem and is intentionally not wired into any orchestration/pipeline. + * + * It checks three structural properties: + * 1. Section headers are well-formed and of a known type. + * 2. `ExtResource(...)` / `SubResource(...)` references resolve to a declared `id`. + * 3. Node `parent="..."` paths resolve to an already-declared node. + */ +class SceneValidator { + + private companion object { + const val CODE_MALFORMED_HEADER = "scene.malformed_header" + const val CODE_UNKNOWN_SECTION = "scene.unknown_section" + const val CODE_UNRESOLVED_RESOURCE = "scene.unresolved_resource" + const val CODE_BAD_PARENT = "scene.bad_parent" + + val KNOWN_SECTIONS = setOf( + "gd_scene", + "gd_resource", + "ext_resource", + "sub_resource", + "node", + "resource", + "connection", + ) + + val EXT_RESOURCE_REF = Regex("""ExtResource\(\s*"?([^")\s]+)"?\s*\)""") + val SUB_RESOURCE_REF = Regex("""SubResource\(\s*"?([^")\s]+)"?\s*\)""") + } + + fun validate(content: String): ValidationReport { + val issues = mutableListOf() + val lines = content.split("\n").map { it.trim() } + + // Collect declared ids up front so resource-reference resolution is independent of the + // order references appear relative to their declarations. + val headers = lines.filter { it.startsWith("[") }.mapNotNull { parseHeader(it) } + val extResourceIds = headers + .filter { it.type == "ext_resource" } + .mapNotNull { it.attributes["id"] } + .toSet() + val subResourceIds = headers + .filter { it.type == "sub_resource" } + .mapNotNull { it.attributes["id"] } + .toSet() + + val knownNodePaths = mutableSetOf() + var rootDeclared = false + + lines.forEachIndexed { index, line -> + val lineNumber = index + 1 + if (line.startsWith("[")) { + val header = parseHeader(line) + if (header == null) { + issues += issue( + CODE_MALFORMED_HEADER, + "Malformed section header at line $lineNumber: '$line'", + ValidationSeverity.ERROR, + ) + return@forEachIndexed + } + if (header.type !in KNOWN_SECTIONS) { + issues += issue( + CODE_UNKNOWN_SECTION, + "Unknown section type '${header.type}' at line $lineNumber", + ValidationSeverity.WARNING, + ) + } + if (header.type == "node") { + handleNode(header, lineNumber, rootDeclared, knownNodePaths, issues) + rootDeclared = true + } + } else { + checkResourceReferences(line, lineNumber, extResourceIds, subResourceIds, issues) + } + } + + return ValidationReport( + sections = listOf(ValidationSection(name = "scene", issues = issues)), + ) + } + + private fun checkResourceReferences( + line: String, + lineNumber: Int, + extResourceIds: Set, + subResourceIds: Set, + issues: MutableList, + ) { + EXT_RESOURCE_REF.findAll(line).forEach { match -> + val id = match.groupValues[1] + if (id !in extResourceIds) { + issues += issue( + CODE_UNRESOLVED_RESOURCE, + "Unresolved ExtResource(\"$id\") at line $lineNumber: no [ext_resource] declares id=\"$id\"", + ValidationSeverity.ERROR, + ) + } + } + SUB_RESOURCE_REF.findAll(line).forEach { match -> + val id = match.groupValues[1] + if (id !in subResourceIds) { + issues += issue( + CODE_UNRESOLVED_RESOURCE, + "Unresolved SubResource(\"$id\") at line $lineNumber: no [sub_resource] declares id=\"$id\"", + ValidationSeverity.ERROR, + ) + } + } + } + + /** + * Registers a node's path into [knownNodePaths]. Emits [CODE_BAD_PARENT] when a non-root node + * references a parent path that has not yet been declared. [rootDeclared] tells whether a root + * node has already been seen (the first node is the root). + */ + private fun handleNode( + header: Header, + lineNumber: Int, + rootDeclared: Boolean, + knownNodePaths: MutableSet, + issues: MutableList, + ) { + val name = header.attributes["name"] + val parent = header.attributes["parent"] + + when { + // First node is the root: no parent, or parent=".". + !rootDeclared -> { + if (parent != null && parent != ".") { + issues += issue( + CODE_BAD_PARENT, + "Root node at line $lineNumber declares parent=\"$parent\" but no parent is declared yet", + ValidationSeverity.ERROR, + ) + } + knownNodePaths += "." + } + parent == null -> issues += issue( + CODE_BAD_PARENT, + "Node '${name ?: "?"}' at line $lineNumber has no parent attribute", + ValidationSeverity.ERROR, + ) + parent !in knownNodePaths -> issues += issue( + CODE_BAD_PARENT, + "Node '${name ?: "?"}' at line $lineNumber references undeclared parent \"$parent\"", + ValidationSeverity.ERROR, + ) + name != null -> knownNodePaths += if (parent == ".") name else "$parent/$name" + } + } + + private fun issue(code: String, message: String, severity: ValidationSeverity) = + ValidationIssue(code = code, message = message, severity = severity) + + private data class Header(val type: String, val attributes: Map) + + /** + * Parses a section header line into its type and `key=value` attributes. Returns `null` when the + * line is not a balanced, parseable header (e.g. unclosed `[`, missing type token). + */ + private fun parseHeader(line: String): Header? { + // The header must be a single balanced [...] pair. + val balanced = line.startsWith("[") && line.endsWith("]") && + line.count { it == '[' } == 1 && line.count { it == ']' } == 1 + val tokens = line.takeIf { balanced } + ?.substring(1, line.length - 1) + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?.let { tokenize(it) } + ?: return null + + // The section type token must be a bare identifier, not a key=value pair. + return tokens.firstOrNull() + ?.takeIf { !it.contains('=') && it.all { ch -> ch.isLetterOrDigit() || ch == '_' } } + ?.let { type -> parseAttributes(tokens.drop(1))?.let { Header(type, it) } } + } + + /** Parses `key=value` attribute tokens. Returns `null` if any token is not a valid pair. */ + private fun parseAttributes(tokens: List): Map? { + val pairs = tokens.map { token -> + val eq = token.indexOf('=') + val key = if (eq > 0) token.substring(0, eq) else "" + if (key.isBlank()) { + null + } else { + key to token.substring(eq + 1).trim().removeSurrounding("\"") + } + } + return if (pairs.any { it == null }) null else pairs.filterNotNull().toMap() + } + + /** + * Splits header inner text into whitespace-separated tokens while respecting double-quoted + * values (which may themselves contain spaces). Returns `null` if a quote is left unterminated. + */ + private fun tokenize(inner: String): List? { + val tokens = mutableListOf() + val current = StringBuilder() + var inQuotes = false + for (ch in inner) { + when { + ch == '"' -> { + inQuotes = !inQuotes + current.append(ch) + } + ch.isWhitespace() && !inQuotes -> { + if (current.isNotEmpty()) { + tokens += current.toString() + current.clear() + } + } + else -> current.append(ch) + } + } + if (inQuotes) return null + if (current.isNotEmpty()) tokens += current.toString() + return tokens + } +} diff --git a/core/validation/src/test/kotlin/com/correx/core/validation/scene/SceneValidatorTest.kt b/core/validation/src/test/kotlin/com/correx/core/validation/scene/SceneValidatorTest.kt new file mode 100644 index 00000000..2db8de04 --- /dev/null +++ b/core/validation/src/test/kotlin/com/correx/core/validation/scene/SceneValidatorTest.kt @@ -0,0 +1,156 @@ +package com.correx.core.validation.scene + +import com.correx.core.validation.model.ValidationIssue +import com.correx.core.validation.model.ValidationReport +import com.correx.core.validation.model.ValidationSeverity +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class SceneValidatorTest { + + private val validator = SceneValidator() + + private fun ValidationReport.issues(): List = + sections.flatMap { it.issues } + + private fun ValidationReport.hasIssue(code: String, severity: ValidationSeverity): Boolean = + issues().any { it.code == code && it.severity == severity } + + @Test + fun `valid tscn with root and two child nodes and a referenced ext_resource has no issues`() { + val content = """ + [gd_scene load_steps=3 format=3 uid="uid://abc123"] + + [ext_resource type="Texture2D" path="res://icon.png" id="1"] + + [node name="Root" type="Node2D"] + + [node name="Sprite" type="Sprite2D" parent="."] + texture = ExtResource("1") + + [node name="Label" type="Label" parent="Sprite"] + text = "hello" + """.trimIndent() + + val report = validator.validate(content) + + assertTrue(report.issues().isEmpty(), "expected no issues, got ${report.issues()}") + assertFalse(report.hasErrors()) + } + + @Test + fun `malformed unclosed header is flagged as error`() { + val content = """ + [gd_scene format=3] + + [node name="X" + """.trimIndent() + + val report = validator.validate(content) + + assertTrue(report.hasIssue("scene.malformed_header", ValidationSeverity.ERROR)) + assertTrue(report.hasErrors()) + } + + @Test + fun `ExtResource referencing an undeclared id is an unresolved resource error`() { + val content = """ + [gd_scene format=3] + + [node name="Root" type="Node2D"] + texture = ExtResource("99") + """.trimIndent() + + val report = validator.validate(content) + + assertTrue(report.hasIssue("scene.unresolved_resource", ValidationSeverity.ERROR)) + } + + @Test + fun `SubResource referencing an undeclared id is an unresolved resource error`() { + val content = """ + [gd_scene format=3] + + [node name="Root" type="Node2D"] + material = SubResource("MissingMat") + """.trimIndent() + + val report = validator.validate(content) + + assertTrue(report.hasIssue("scene.unresolved_resource", ValidationSeverity.ERROR)) + } + + @Test + fun `node with non-existent parent is a bad parent error`() { + val content = """ + [gd_scene format=3] + + [node name="Root" type="Node2D"] + + [node name="Child" type="Node2D" parent="DoesNotExist"] + """.trimIndent() + + val report = validator.validate(content) + + assertTrue(report.hasIssue("scene.bad_parent", ValidationSeverity.ERROR)) + } + + @Test + fun `valid tres resource file with ext and sub resources has no issues`() { + val content = """ + [gd_resource type="StyleBoxFlat" load_steps=2 format=3 uid="uid://xyz"] + + [ext_resource type="Texture2D" path="res://tex.png" id="1"] + + [sub_resource type="Gradient" id="grad_1"] + colors = PackedColorArray(1, 1, 1, 1) + + [resource] + texture = ExtResource("1") + gradient = SubResource("grad_1") + """.trimIndent() + + val report = validator.validate(content) + + assertTrue(report.issues().isEmpty(), "expected no issues, got ${report.issues()}") + assertFalse(report.hasErrors()) + } + + @Test + fun `unknown section type is a warning`() { + val content = """ + [gd_scene format=3] + + [bogus_section] + + [node name="Root" type="Node2D"] + """.trimIndent() + + val report = validator.validate(content) + + assertTrue(report.hasIssue("scene.unknown_section", ValidationSeverity.WARNING)) + // An unknown section is a warning, not an error. + assertFalse(report.hasErrors()) + } + + @Test + fun `deeper parent path resolves when the intermediate node was declared`() { + val content = """ + [gd_scene format=3] + + [node name="Root" type="Node2D"] + + [node name="A" type="Node2D" parent="."] + + [node name="B" type="Node2D" parent="A"] + + [node name="C" type="Node2D" parent="A/B"] + """.trimIndent() + + val report = validator.validate(content) + + assertFalse(report.hasIssue("scene.bad_parent", ValidationSeverity.ERROR)) + assertTrue(report.issues().isEmpty()) + } +} From 1df7af5b856c22dcfa746b6537ee0490ce7b7431 Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 20 Jun 2026 10:42:49 +0000 Subject: [PATCH 006/107] feat(kernel): brief echo-back gate (C-A1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BriefEcho model + BriefEchoMismatchEvent (registered + serialization test) - BriefEchoExtractor: parses the planner's brief_echo block and the original brief's referenced-files/symbols/acceptance-criteria dimensions - BriefEchoComparator: conservative diff — a dropped acceptance criterion or an invented file path halts; dropping a file/symbol alone does not - checkBriefEcho gate chained into the post-stage flow after groundBriefReferences; opt-in via stageConfig.metadata["briefEcho"], fails the stage (retryable) on divergence with re-grounding feedback - prompt/workflow activation (planner.md emitting brief_echo; role_pipeline metadata) left as live-QA follow-up Co-Authored-By: Claude Opus 4.8 --- .../core/events/events/BriefEchoEvents.kt | 38 ++++++++++ .../events/serialization/Serialization.kt | 2 + .../BriefEchoEventSerializationTest.kt | 28 ++++++++ .../orchestration/BriefEchoComparator.kt | 59 +++++++++++++++ .../orchestration/BriefEchoExtractor.kt | 61 ++++++++++++++++ .../orchestration/SessionOrchestrator.kt | 71 ++++++++++++++++++- .../orchestration/BriefEchoComparatorTest.kt | 68 ++++++++++++++++++ .../orchestration/BriefEchoExtractorTest.kt | 62 ++++++++++++++++ 8 files changed, 388 insertions(+), 1 deletion(-) create mode 100644 core/events/src/main/kotlin/com/correx/core/events/events/BriefEchoEvents.kt create mode 100644 core/events/src/test/kotlin/com/correx/core/events/serialization/BriefEchoEventSerializationTest.kt create mode 100644 core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/BriefEchoComparator.kt create mode 100644 core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/BriefEchoExtractor.kt create mode 100644 core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/BriefEchoComparatorTest.kt create mode 100644 core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/BriefEchoExtractorTest.kt diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/BriefEchoEvents.kt b/core/events/src/main/kotlin/com/correx/core/events/events/BriefEchoEvents.kt new file mode 100644 index 00000000..a465d50d --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/events/BriefEchoEvents.kt @@ -0,0 +1,38 @@ +package com.correx.core.events.events + +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * A planner's structured restatement of the brief it was handed: the files it must touch, the + * symbols it must reach, and the acceptance criteria it must satisfy. The harness diffs this echo + * against the original brief before any plan is generated, so a divergence halts at the cheapest + * point in the pipeline. Also reused as the diff input shape for both sides of the comparison. + */ +@Serializable +data class BriefEcho( + val referencedFiles: Set = emptySet(), + val symbols: Set = emptySet(), + val acceptanceCriteria: Set = emptySet(), +) + +/** + * A stage echoed the brief but the echo diverged from the original, so the plan is rejected + * before generation ("halt before plan generation"). Recorded so replay explains the halt. + * + * - [droppedFiles] / [droppedSymbols] / [droppedAcceptanceCriteria]: present in the original brief, + * missing from the echo — the planner failed to carry them forward. + * - [inventedFiles]: file paths in the echo that are not in the original brief — hallucinated paths. + */ +@Serializable +@SerialName("BriefEchoMismatch") +data class BriefEchoMismatchEvent( + val sessionId: SessionId, + val stageId: StageId, + val droppedFiles: Set, + val droppedSymbols: Set, + val droppedAcceptanceCriteria: Set, + val inventedFiles: Set, +) : EventPayload diff --git a/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt b/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt index 20d6ed08..08076475 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt @@ -8,6 +8,7 @@ import com.correx.core.events.events.ArtifactContentStoredEvent import com.correx.core.events.events.ArtifactCreatedEvent import com.correx.core.events.events.ArtifactValidatedEvent import com.correx.core.events.events.ArtifactValidatingEvent +import com.correx.core.events.events.BriefEchoMismatchEvent import com.correx.core.events.events.BriefGroundingCheckedEvent import com.correx.core.events.events.ChatSessionStartedEvent import com.correx.core.events.events.ClarificationAnsweredEvent @@ -109,6 +110,7 @@ val eventModule = SerializersModule { subclass(WorkspaceStateObservedEvent::class) subclass(RepoKnowledgeRetrievedEvent::class) subclass(BriefGroundingCheckedEvent::class) + subclass(BriefEchoMismatchEvent::class) subclass(RiskAssessedEvent::class) subclass(ChatSessionStartedEvent::class) subclass(ChatTurnEvent::class) diff --git a/core/events/src/test/kotlin/com/correx/core/events/serialization/BriefEchoEventSerializationTest.kt b/core/events/src/test/kotlin/com/correx/core/events/serialization/BriefEchoEventSerializationTest.kt new file mode 100644 index 00000000..f93be4d1 --- /dev/null +++ b/core/events/src/test/kotlin/com/correx/core/events/serialization/BriefEchoEventSerializationTest.kt @@ -0,0 +1,28 @@ +package com.correx.core.events.serialization + +import com.correx.core.events.events.BriefEchoMismatchEvent +import com.correx.core.events.events.EventPayload +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class BriefEchoEventSerializationTest { + + @Test + fun `BriefEchoMismatchEvent round-trips as polymorphic EventPayload`() { + val sample: EventPayload = BriefEchoMismatchEvent( + sessionId = SessionId("s"), + stageId = StageId("plan"), + droppedFiles = setOf("core/kernel/Foo.kt"), + droppedSymbols = setOf("Foo"), + droppedAcceptanceCriteria = setOf("must compile"), + inventedFiles = setOf("core/kernel/Bar.kt"), + ) + val encoded = eventJson.encodeToString(EventPayload.serializer(), sample) + assertTrue(encoded.contains("\"type\":\"BriefEchoMismatch\""), "SerialName must be present: $encoded") + val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded) + assertEquals(sample, decoded) + } +} diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/BriefEchoComparator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/BriefEchoComparator.kt new file mode 100644 index 00000000..358de585 --- /dev/null +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/BriefEchoComparator.kt @@ -0,0 +1,59 @@ +package com.correx.core.kernel.orchestration + +import com.correx.core.events.events.BriefEcho + +/** + * The divergence between an original brief and a planner's echo of it. + * + * - [droppedFiles] / [droppedSymbols] / [droppedAcceptanceCriteria]: present in the original brief, + * missing from the echo — the planner failed to carry them forward. + * - [inventedFiles]: file paths in the echo not in the original — hallucinated paths. + * + * [hasMismatch] is conservative: see [BriefEchoComparator.compare]. + */ +internal data class BriefEchoDiff( + val droppedFiles: Set, + val droppedSymbols: Set, + val droppedAcceptanceCriteria: Set, + val inventedFiles: Set, +) { + /** + * A mismatch that should halt the stage. Only dropping a *required acceptance criterion* or + * *inventing a file path* qualifies: those are correctness-bearing. Merely dropping a file or + * symbol the plan may legitimately not echo is tolerated and does NOT halt. + */ + val hasMismatch: Boolean + get() = droppedAcceptanceCriteria.isNotEmpty() || inventedFiles.isNotEmpty() +} + +/** + * Diffs a planner's [BriefEcho] against the original brief's. Pure and deterministic. + * + * Diff semantics: + * - `dropped* = original.X - echo.X` (the planner failed to carry something forward). + * - `inventedFiles = echo.referencedFiles - original.referencedFiles` (hallucinated paths). + * + * Comparison is case-insensitive and trimmed for acceptance criteria and symbols (free-text the + * model may rephrase in casing/whitespace), and exact for file paths (a path is a path). + */ +internal object BriefEchoComparator { + + fun compare(original: BriefEcho, echo: BriefEcho): BriefEchoDiff { + val droppedFiles = original.referencedFiles - echo.referencedFiles + val inventedFiles = echo.referencedFiles - original.referencedFiles + return BriefEchoDiff( + droppedFiles = droppedFiles, + droppedSymbols = normalizedDrop(original.symbols, echo.symbols), + droppedAcceptanceCriteria = normalizedDrop(original.acceptanceCriteria, echo.acceptanceCriteria), + inventedFiles = inventedFiles, + ) + } + + /** Original entries whose normalized form is absent from the echo, returned in original form. */ + private fun normalizedDrop(original: Set, echo: Set): Set { + val echoKeys = echo.mapTo(HashSet()) { it.normalize() } + return original.filterTo(LinkedHashSet()) { it.normalize() !in echoKeys } + } + + private fun String.normalize(): String = trim().lowercase() +} diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/BriefEchoExtractor.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/BriefEchoExtractor.kt new file mode 100644 index 00000000..a2ae0e37 --- /dev/null +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/BriefEchoExtractor.kt @@ -0,0 +1,61 @@ +package com.correx.core.kernel.orchestration + +import com.correx.core.events.events.BriefEcho +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +/** + * Extracts the structured [BriefEcho] from two artifact shapes the echo gate compares: + * + * - [fromEcho]: a planner's produced artifact, which restates the brief under a `brief_echo` key: + * `{"brief_echo": {"referenced_files": [...], "symbols": [...], "acceptance_criteria": [...]}}`. + * - [fromOriginalBrief]: the original (analyst) brief artifact, whose three dimensions are pulled + * best-effort — referenced files via [BriefReferenceExtractor], plus optional `acceptance_criteria` + * and `symbols` arrays. + * + * Both are deterministic and tolerant of missing keys, parsing with kotlinx-serialization Json + * like [BriefReferenceExtractor]. + */ +internal object BriefEchoExtractor { + + private const val BRIEF_ECHO_KEY = "brief_echo" + private const val REFERENCED_FILES_KEY = "referenced_files" + private const val SYMBOLS_KEY = "symbols" + private const val ACCEPTANCE_CRITERIA_KEY = "acceptance_criteria" + + /** Returns the `brief_echo` block as a [BriefEcho], or null if the artifact has no such key. */ + fun fromEcho(artifactJson: String): BriefEcho? { + val root = artifactJson.parseObjectOrNull() ?: return null + val echo = root[BRIEF_ECHO_KEY] as? JsonObject ?: return null + return BriefEcho( + referencedFiles = echo.stringSet(REFERENCED_FILES_KEY), + symbols = echo.stringSet(SYMBOLS_KEY), + acceptanceCriteria = echo.stringSet(ACCEPTANCE_CRITERIA_KEY), + ) + } + + /** The same three dimensions pulled from the original brief artifact, best-effort. */ + fun fromOriginalBrief(artifactJson: String): BriefEcho { + val root = artifactJson.parseObjectOrNull() + return BriefEcho( + referencedFiles = BriefReferenceExtractor.fileReferences(artifactJson).toSet(), + symbols = root?.stringSet(SYMBOLS_KEY) ?: emptySet(), + acceptanceCriteria = root?.stringSet(ACCEPTANCE_CRITERIA_KEY) ?: emptySet(), + ) + } + + private fun String.parseObjectOrNull(): JsonObject? = + runCatching { Json.parseToJsonElement(this) }.getOrNull() as? JsonObject + + /** Non-blank string leaves of the named array, in order; empty when the key is absent/non-array. */ + private fun JsonObject.stringSet(key: String): Set { + val array = this[key] as? JsonArray ?: return emptySet() + return array.mapNotNullTo(LinkedHashSet()) { it.stringOrNull()?.takeIf(String::isNotBlank) } + } + + private fun JsonElement.stringOrNull(): String? = + (this as? JsonPrimitive)?.takeIf { it.isString }?.content +} diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt index cdfdd5f2..85bca6c1 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt @@ -28,6 +28,7 @@ import com.correx.core.events.events.RefinementIterationEvent import com.correx.core.events.events.ApprovalRequestedEvent import com.correx.core.events.events.ArtifactContentStoredEvent import com.correx.core.events.events.ArtifactCreatedEvent +import com.correx.core.events.events.BriefEchoMismatchEvent import com.correx.core.events.events.BriefGroundingCheckedEvent import com.correx.core.events.events.ContextTruncatedEvent import com.correx.core.events.events.ClarificationAnswer @@ -550,7 +551,11 @@ abstract class SessionOrchestrator( emitLlmArtifacts(sessionId, stageId, stageConfig) when (val produced = verifyProduces(sessionId, stageId, stageConfig)) { is StageExecutionResult.Success -> - groundBriefReferences(sessionId, stageId, stageConfig, effectives) + when (val grounded = groundBriefReferences(sessionId, stageId, stageConfig, effectives)) { + is StageExecutionResult.Success -> + checkBriefEcho(sessionId, stageId, stageConfig) + is StageExecutionResult.Failure -> grounded + } is StageExecutionResult.Failure -> produced } } @@ -1399,6 +1404,70 @@ abstract class SessionOrchestrator( } } + /** + * Brief echo-back gate (BACKLOG §C-A1): for a stage that opted in (`briefEcho`), the planner must + * restate the brief as a structured `brief_echo` block before any plan is generated. The echo is + * diffed against the original brief; on divergence a [BriefEchoMismatchEvent] is emitted and the + * stage fails (retryable), so the plan is never accepted — the cheapest failure point in the + * pipeline. Mismatch is conservative ([BriefEchoComparator]): a dropped acceptance criterion or an + * invented file path halts; merely dropping a file/symbol does not. + */ + private suspend fun checkBriefEcho( + sessionId: SessionId, + stageId: StageId, + stageConfig: StageConfig, + ): StageExecutionResult { + if (stageConfig.metadata["briefEcho"] != "true") return StageExecutionResult.Success(emptyList()) + val sourceSlot = stageConfig.metadata["briefEchoSource"] ?: "brief" + val original = artifactContentCache["${sessionId.value}:$sourceSlot"] + ?: return StageExecutionResult.Success(emptyList()) + + val produced = stageConfig.produces + .filter { it.kind.llmEmitted } + .firstNotNullOfOrNull { artifactContentCache["${sessionId.value}:${it.name.value}"] } + ?: return StageExecutionResult.Success(emptyList()) + + val echo = BriefEchoExtractor.fromEcho(produced) + ?: return StageExecutionResult.Failure( + "stage ${stageId.value} must restate the brief as a brief_echo block before planning", + retryable = true, + ) + + val diff = BriefEchoComparator.compare(BriefEchoExtractor.fromOriginalBrief(original), echo) + if (!diff.hasMismatch) return StageExecutionResult.Success(emptyList()) + + emit( + sessionId, + BriefEchoMismatchEvent( + sessionId = sessionId, + stageId = stageId, + droppedFiles = diff.droppedFiles, + droppedSymbols = diff.droppedSymbols, + droppedAcceptanceCriteria = diff.droppedAcceptanceCriteria, + inventedFiles = diff.inventedFiles, + ), + ) + log.warn( + "[Orchestrator] brief echo diverged session={} stage={} droppedCriteria={} invented={}", + sessionId.value, stageId.value, + diff.droppedAcceptanceCriteria.joinToString(", "), + diff.inventedFiles.joinToString(", "), + ) + return StageExecutionResult.Failure( + buildString { + append("stage ${stageId.value} brief_echo diverged from the brief.") + if (diff.droppedAcceptanceCriteria.isNotEmpty()) { + append(" Restate these acceptance criteria: ${diff.droppedAcceptanceCriteria.joinToString(", ")}.") + } + if (diff.inventedFiles.isNotEmpty()) { + append(" These files are not in the brief — do not invent paths: " + + "${diff.inventedFiles.joinToString(", ")}.") + } + }, + retryable = true, + ) + } + private suspend fun emitProcessResultEvents( sessionId: SessionId, stageId: StageId, diff --git a/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/BriefEchoComparatorTest.kt b/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/BriefEchoComparatorTest.kt new file mode 100644 index 00000000..170813e8 --- /dev/null +++ b/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/BriefEchoComparatorTest.kt @@ -0,0 +1,68 @@ +package com.correx.core.kernel.orchestration + +import com.correx.core.events.events.BriefEcho +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 + +class BriefEchoComparatorTest { + + @Test + fun `identical echo has no mismatch`() { + val brief = BriefEcho( + referencedFiles = setOf("core/kernel/Foo.kt"), + symbols = setOf("Foo"), + acceptanceCriteria = setOf("must compile"), + ) + val diff = BriefEchoComparator.compare(brief, brief) + assertFalse(diff.hasMismatch) + assertTrue(diff.droppedFiles.isEmpty()) + assertTrue(diff.inventedFiles.isEmpty()) + } + + @Test + fun `dropped acceptance criterion is a mismatch`() { + val original = BriefEcho(acceptanceCriteria = setOf("must compile", "must pass tests")) + val echo = BriefEcho(acceptanceCriteria = setOf("must compile")) + val diff = BriefEchoComparator.compare(original, echo) + assertTrue(diff.hasMismatch) + assertEquals(setOf("must pass tests"), diff.droppedAcceptanceCriteria) + } + + @Test + fun `invented file is a mismatch`() { + val original = BriefEcho(referencedFiles = setOf("core/kernel/Foo.kt")) + val echo = BriefEcho(referencedFiles = setOf("core/kernel/Foo.kt", "core/kernel/Bar.kt")) + val diff = BriefEchoComparator.compare(original, echo) + assertTrue(diff.hasMismatch) + assertEquals(setOf("core/kernel/Bar.kt"), diff.inventedFiles) + } + + @Test + fun `dropped file only is not a mismatch`() { + val original = BriefEcho(referencedFiles = setOf("core/kernel/Foo.kt", "core/kernel/Bar.kt")) + val echo = BriefEcho(referencedFiles = setOf("core/kernel/Foo.kt")) + val diff = BriefEchoComparator.compare(original, echo) + assertFalse(diff.hasMismatch) + assertEquals(setOf("core/kernel/Bar.kt"), diff.droppedFiles) + } + + @Test + fun `acceptance criterion match is case-insensitive and trimmed`() { + val original = BriefEcho(acceptanceCriteria = setOf("Must Compile")) + val echo = BriefEcho(acceptanceCriteria = setOf(" must compile ")) + val diff = BriefEchoComparator.compare(original, echo) + assertFalse(diff.hasMismatch) + assertTrue(diff.droppedAcceptanceCriteria.isEmpty()) + } + + @Test + fun `dropped symbol is not a mismatch but is reported`() { + val original = BriefEcho(symbols = setOf("Foo", "Bar")) + val echo = BriefEcho(symbols = setOf("foo")) + val diff = BriefEchoComparator.compare(original, echo) + assertFalse(diff.hasMismatch) + assertEquals(setOf("Bar"), diff.droppedSymbols) + } +} diff --git a/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/BriefEchoExtractorTest.kt b/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/BriefEchoExtractorTest.kt new file mode 100644 index 00000000..1f51f7c6 --- /dev/null +++ b/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/BriefEchoExtractorTest.kt @@ -0,0 +1,62 @@ +package com.correx.core.kernel.orchestration + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class BriefEchoExtractorTest { + + @Test + fun `parses a well-formed brief_echo block`() { + val json = """ + { + "brief_echo": { + "referenced_files": ["core/kernel/Foo.kt", "core/kernel/Bar.kt"], + "symbols": ["Foo"], + "acceptance_criteria": ["must compile", "must pass tests"] + } + } + """.trimIndent() + val echo = BriefEchoExtractor.fromEcho(json)!! + assertEquals(setOf("core/kernel/Foo.kt", "core/kernel/Bar.kt"), echo.referencedFiles) + assertEquals(setOf("Foo"), echo.symbols) + assertEquals(setOf("must compile", "must pass tests"), echo.acceptanceCriteria) + } + + @Test + fun `returns null when brief_echo key is absent`() { + assertNull(BriefEchoExtractor.fromEcho("""{"summary": "do the thing"}""")) + } + + @Test + fun `tolerates missing dimensions in brief_echo`() { + val echo = BriefEchoExtractor.fromEcho("""{"brief_echo": {"referenced_files": ["a/b/C.kt"]}}""")!! + assertEquals(setOf("a/b/C.kt"), echo.referencedFiles) + assertTrue(echo.symbols.isEmpty()) + assertTrue(echo.acceptanceCriteria.isEmpty()) + } + + @Test + fun `original-brief extraction pulls referenced files and acceptance criteria`() { + val json = """ + { + "summary": "touch core/kernel/Foo.kt to satisfy the gate", + "acceptance_criteria": ["must compile", "must pass tests"], + "symbols": ["Foo"] + } + """.trimIndent() + val original = BriefEchoExtractor.fromOriginalBrief(json) + assertTrue(original.referencedFiles.contains("core/kernel/Foo.kt")) + assertEquals(setOf("must compile", "must pass tests"), original.acceptanceCriteria) + assertEquals(setOf("Foo"), original.symbols) + } + + @Test + fun `original-brief extraction tolerates missing acceptance_criteria and symbols`() { + val original = BriefEchoExtractor.fromOriginalBrief("""{"affected": ["core/kernel/Foo.kt"]}""") + assertTrue(original.referencedFiles.contains("core/kernel/Foo.kt")) + assertTrue(original.acceptanceCriteria.isEmpty()) + assertTrue(original.symbols.isEmpty()) + } +} From 1a1b5cc5ed99ad43803433d7b3933ff19fdd8d70 Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 20 Jun 2026 10:53:41 +0000 Subject: [PATCH 007/107] feat(kernel): stage-level plan checkpointing (C-A2) - StageCheckpointPassed/FailedEvent (registered + serialization test): per-stage reconciliation of produced artifacts vs the locked plan's produces-slots - StageCheckpointReconciler (pure) + tests - emitStageCheckpoint wired in after verifyProduces, runs regardless of its pass/fail (verifyProduces still owns the halt); gated on an ExecutionPlanLocked in the session log so non-plan workflows are unaffected Co-Authored-By: Claude Opus 4.8 --- .../events/events/StageCheckpointEvents.kt | 42 ++++++++++++++++ .../events/serialization/Serialization.kt | 4 ++ .../StageCheckpointEventSerializationTest.kt | 42 ++++++++++++++++ .../orchestration/SessionOrchestrator.kt | 33 ++++++++++++- .../StageCheckpointReconciler.kt | 23 +++++++++ .../StageCheckpointReconcilerTest.kt | 49 +++++++++++++++++++ 6 files changed, 192 insertions(+), 1 deletion(-) create mode 100644 core/events/src/main/kotlin/com/correx/core/events/events/StageCheckpointEvents.kt create mode 100644 core/events/src/test/kotlin/com/correx/core/events/serialization/StageCheckpointEventSerializationTest.kt create mode 100644 core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/StageCheckpointReconciler.kt create mode 100644 core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/StageCheckpointReconcilerTest.kt diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/StageCheckpointEvents.kt b/core/events/src/main/kotlin/com/correx/core/events/events/StageCheckpointEvents.kt new file mode 100644 index 00000000..e0e8cb6a --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/events/StageCheckpointEvents.kt @@ -0,0 +1,42 @@ +package com.correx.core.events.events + +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Per-stage reconciliation of a stage's produced artifacts against the locked plan's produces-slots, + * recorded so plan adherence is observable and replayable. Emitted only on plan-driven (phase-2) + * runs — a session whose log contains an [ExecutionPlanLockedEvent]; non-plan workflows emit nothing. + * + * The *passed* variant means every expected slot was produced: [producedArtifacts] is a superset of + * [expectedArtifacts] (extra artifacts beyond the expected set do not fail the checkpoint). + */ +@Serializable +@SerialName("StageCheckpointPassed") +data class StageCheckpointPassedEvent( + val sessionId: SessionId, + val stageId: StageId, + val expectedArtifacts: Set, + val producedArtifacts: Set, +) : EventPayload + +/** + * Per-stage reconciliation of a stage's produced artifacts against the locked plan's produces-slots, + * recorded so plan adherence is observable and replayable. Emitted only on plan-driven (phase-2) + * runs — a session whose log contains an [ExecutionPlanLockedEvent]; non-plan workflows emit nothing. + * + * The *failed* variant accompanies the stage halt owned by `verifyProduces` and surfaces *why* the + * plan diverged: [missingArtifacts] are the expected slots ([expectedArtifacts] minus + * [producedArtifacts]) the stage never produced. + */ +@Serializable +@SerialName("StageCheckpointFailed") +data class StageCheckpointFailedEvent( + val sessionId: SessionId, + val stageId: StageId, + val expectedArtifacts: Set, + val producedArtifacts: Set, + val missingArtifacts: Set, +) : EventPayload diff --git a/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt b/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt index 08076475..32fb6e6a 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt @@ -45,6 +45,8 @@ import com.correx.core.events.events.RepoMapComputedEvent import com.correx.core.events.events.RetryAttemptedEvent import com.correx.core.events.events.WorkspaceStateObservedEvent import com.correx.core.events.events.RiskAssessedEvent +import com.correx.core.events.events.StageCheckpointFailedEvent +import com.correx.core.events.events.StageCheckpointPassedEvent import com.correx.core.events.events.StageCompletedEvent import com.correx.core.events.events.PreemptRedirectBlockedEvent import com.correx.core.events.events.PreemptRedirectEvent @@ -131,6 +133,8 @@ val eventModule = SerializersModule { subclass(IdeaCapturedEvent::class) subclass(IdeaDiscardedEvent::class) subclass(CritiqueOutcomeCorrelatedEvent::class) + subclass(StageCheckpointPassedEvent::class) + subclass(StageCheckpointFailedEvent::class) } } diff --git a/core/events/src/test/kotlin/com/correx/core/events/serialization/StageCheckpointEventSerializationTest.kt b/core/events/src/test/kotlin/com/correx/core/events/serialization/StageCheckpointEventSerializationTest.kt new file mode 100644 index 00000000..d84db0a5 --- /dev/null +++ b/core/events/src/test/kotlin/com/correx/core/events/serialization/StageCheckpointEventSerializationTest.kt @@ -0,0 +1,42 @@ +package com.correx.core.events.serialization + +import com.correx.core.events.events.EventPayload +import com.correx.core.events.events.StageCheckpointFailedEvent +import com.correx.core.events.events.StageCheckpointPassedEvent +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class StageCheckpointEventSerializationTest { + + @Test + fun `StageCheckpointPassedEvent round-trips as polymorphic EventPayload`() { + val sample: EventPayload = StageCheckpointPassedEvent( + sessionId = SessionId("s"), + stageId = StageId("plan"), + expectedArtifacts = setOf("plan.json"), + producedArtifacts = setOf("plan.json", "notes.md"), + ) + val encoded = eventJson.encodeToString(EventPayload.serializer(), sample) + assertTrue(encoded.contains("\"type\":\"StageCheckpointPassed\""), "SerialName must be present: $encoded") + val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded) + assertEquals(sample, decoded) + } + + @Test + fun `StageCheckpointFailedEvent round-trips as polymorphic EventPayload`() { + val sample: EventPayload = StageCheckpointFailedEvent( + sessionId = SessionId("s"), + stageId = StageId("plan"), + expectedArtifacts = setOf("plan.json", "diff.patch"), + producedArtifacts = setOf("plan.json"), + missingArtifacts = setOf("diff.patch"), + ) + val encoded = eventJson.encodeToString(EventPayload.serializer(), sample) + assertTrue(encoded.contains("\"type\":\"StageCheckpointFailed\""), "SerialName must be present: $encoded") + val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded) + assertEquals(sample, decoded) + } +} diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt index 85bca6c1..3d3d946b 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt @@ -53,6 +53,8 @@ import com.correx.core.events.events.OrchestrationResumedEvent import com.correx.core.events.events.RiskAssessedEvent import com.correx.core.events.events.ExecutionPlanLockedEvent import com.correx.core.events.events.FileWrittenEvent +import com.correx.core.events.events.StageCheckpointFailedEvent +import com.correx.core.events.events.StageCheckpointPassedEvent import com.correx.core.events.events.SteeringNoteAddedEvent import com.correx.core.events.events.ToolExecutionCompletedEvent import com.correx.core.events.events.ToolExecutionFailedEvent @@ -549,7 +551,9 @@ abstract class SessionOrchestrator( is StageExecutionResult.Success -> { emitToolArtifacts(sessionId, stageId, stageConfig) emitLlmArtifacts(sessionId, stageId, stageConfig) - when (val produced = verifyProduces(sessionId, stageId, stageConfig)) { + val produced = verifyProduces(sessionId, stageId, stageConfig) + emitStageCheckpoint(sessionId, stageId, stageConfig) + when (produced) { is StageExecutionResult.Success -> when (val grounded = groundBriefReferences(sessionId, stageId, stageConfig, effectives)) { is StageExecutionResult.Success -> @@ -1360,6 +1364,33 @@ abstract class SessionOrchestrator( ) } + /** + * Stage-level plan checkpointing (BACKLOG §C-A2): make plan adherence observable and replayable + * by emitting a first-class checkpoint event per stage, reconciling produced-vs-expected against + * the confirmed (locked) plan. Only runs on plan-driven (phase-2) sessions — i.e. a log that + * holds an [ExecutionPlanLockedEvent]; non-plan workflows emit nothing. The halt itself is owned + * by [verifyProduces]; this method only EMITS — a [StageCheckpointFailedEvent] accompanies that + * halt and surfaces *why* the plan diverged. + */ + private suspend fun emitStageCheckpoint( + sessionId: SessionId, + stageId: StageId, + stageConfig: StageConfig, + ) { + if (eventStore.read(sessionId).none { it.payload is ExecutionPlanLockedEvent }) return + val expected = stageConfig.produces.map { it.name.value }.toSet() + if (expected.isEmpty()) return + val produced = eventStore.read(sessionId) + .mapNotNull { (it.payload as? ArtifactCreatedEvent)?.artifactId?.value } + .toSet() + val cp = StageCheckpointReconciler.reconcile(expected, produced) + if (cp.passed) { + emit(sessionId, StageCheckpointPassedEvent(sessionId, stageId, expected, produced)) + } else { + emit(sessionId, StageCheckpointFailedEvent(sessionId, stageId, expected, produced, cp.missing)) + } + } + /** * Entity grounding (role-reliability §3): for a stage that opted in (`ground_references`), * check every workspace-relative file path its brief named against the filesystem. Existence diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/StageCheckpointReconciler.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/StageCheckpointReconciler.kt new file mode 100644 index 00000000..de206782 --- /dev/null +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/StageCheckpointReconciler.kt @@ -0,0 +1,23 @@ +package com.correx.core.kernel.orchestration + +/** + * The reconciliation of a stage's [produced] artifacts against the [expected] produces-slots of the + * locked plan. [missing] is the expected slots that were never produced; [passed] holds when none + * are missing. Extra produced artifacts beyond [expected] do not fail the checkpoint. + */ +internal data class StageCheckpoint( + val expected: Set, + val produced: Set, +) { + val missing: Set get() = expected - produced + val passed: Boolean get() = missing.isEmpty() +} + +/** + * Pure reconciler for stage-level plan checkpointing: compares the artifacts a stage produced + * against the artifacts the locked plan expected it to produce. Side-effect free and replay-safe. + */ +internal object StageCheckpointReconciler { + fun reconcile(expected: Set, produced: Set): StageCheckpoint = + StageCheckpoint(expected = expected, produced = produced) +} diff --git a/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/StageCheckpointReconcilerTest.kt b/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/StageCheckpointReconcilerTest.kt new file mode 100644 index 00000000..e325a166 --- /dev/null +++ b/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/StageCheckpointReconcilerTest.kt @@ -0,0 +1,49 @@ +package com.correx.core.kernel.orchestration + +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 + +class StageCheckpointReconcilerTest { + + @Test + fun `all expected produced - passed with no missing`() { + val cp = StageCheckpointReconciler.reconcile( + expected = setOf("plan.json", "diff.patch"), + produced = setOf("plan.json", "diff.patch"), + ) + assertTrue(cp.passed) + assertTrue(cp.missing.isEmpty()) + } + + @Test + fun `one missing - not passed with correct missing set`() { + val cp = StageCheckpointReconciler.reconcile( + expected = setOf("plan.json", "diff.patch"), + produced = setOf("plan.json"), + ) + assertFalse(cp.passed) + assertEquals(setOf("diff.patch"), cp.missing) + } + + @Test + fun `empty expected - passed`() { + val cp = StageCheckpointReconciler.reconcile( + expected = emptySet(), + produced = setOf("plan.json"), + ) + assertTrue(cp.passed) + assertTrue(cp.missing.isEmpty()) + } + + @Test + fun `extra produced beyond expected - still passed`() { + val cp = StageCheckpointReconciler.reconcile( + expected = setOf("plan.json"), + produced = setOf("plan.json", "notes.md", "scratch.txt"), + ) + assertTrue(cp.passed) + assertTrue(cp.missing.isEmpty()) + } +} From eae0a0ccf6da7becf377a1ef270286f3dc6d9bca Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 20 Jun 2026 11:31:39 +0000 Subject: [PATCH 008/107] =?UTF-8?q?feat(memory):=20architect=20contradicti?= =?UTF-8?q?on-check=20(B=C2=A74,=20display-only)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PossibleContradictionFlaggedEvent + RelatedDecision (registered + serialization test) - ArchitectContradictionChecker: L3 similarity retrieval over prior decisions, threshold-gated, fake-testable; non-blocking (surfaces candidates only) - live wiring left as documented TODO (needs an architect-decision emit hook + in-session decision embedding into L3) Co-Authored-By: Claude Opus 4.8 --- .../kotlin/com/correx/apps/server/Main.kt | 10 ++ .../memory/ArchitectContradictionChecker.kt | 70 ++++++++++++ .../ArchitectContradictionCheckerTest.kt | 107 ++++++++++++++++++ .../core/events/events/ContradictionEvents.kt | 33 ++++++ .../events/serialization/Serialization.kt | 2 + .../ContradictionEventSerializationTest.kt | 36 ++++++ 6 files changed, 258 insertions(+) create mode 100644 apps/server/src/main/kotlin/com/correx/apps/server/memory/ArchitectContradictionChecker.kt create mode 100644 apps/server/src/test/kotlin/com/correx/apps/server/memory/ArchitectContradictionCheckerTest.kt create mode 100644 core/events/src/main/kotlin/com/correx/core/events/events/ContradictionEvents.kt create mode 100644 core/events/src/test/kotlin/com/correx/core/events/serialization/ContradictionEventSerializationTest.kt diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt index f7c06d79..4cbc18e5 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt @@ -380,6 +380,16 @@ fun main() { privilegedLocations = privilegedLocations, allowedWorkspaceRoots = allowedWorkspaceRoots, ) + // TODO(wiring): ArchitectContradictionChecker (BACKLOG §B-§4) is complete but not yet live. + // `embedder` + `l3MemoryStore` are in scope here, but two preconditions are missing: + // (1) no server-side hook exposes the architect's decision *text* with an event-emit path + // (architect is a workflow-defined role/stage, not a known stage id; decisions become + // DecisionRecords via the journal reducer from generic events), and + // (2) decisions are only embedded into L3 at session end (ProjectMemoryService.persist), + // under turnId "project:", so an in-session architect run has nothing of its + // own to retrieve. Wiring this would mean inventing an architect-stage subscriber + + // emit path — deliberately left out per scope. To go live: subscribe to the architect + // decision, call ArchitectContradictionChecker.check(...), and emit the non-null result. // Built from a config snapshot and reused by ConfigService's rebuild hook so toggling // project.enabled / personalization.* applies live to the next session. fun buildProjectMemory(cfg: CorrexConfig): com.correx.apps.server.memory.ProjectMemoryService? = diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/memory/ArchitectContradictionChecker.kt b/apps/server/src/main/kotlin/com/correx/apps/server/memory/ArchitectContradictionChecker.kt new file mode 100644 index 00000000..bafddd21 --- /dev/null +++ b/apps/server/src/main/kotlin/com/correx/apps/server/memory/ArchitectContradictionChecker.kt @@ -0,0 +1,70 @@ +package com.correx.apps.server.memory + +import com.correx.core.events.events.PossibleContradictionFlaggedEvent +import com.correx.core.events.events.RelatedDecision +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.inference.Embedder +import com.correx.core.router.l3.L3MemoryStore +import com.correx.core.router.l3.L3Query + +/** + * Display-only architect contradiction surfacing (BACKLOG §B-§4). When the architect records a + * decision, this embeds the decision text, retrieves semantically-near PRIOR decisions/ADRs from + * L3, and — if any clear the similarity threshold — returns a [PossibleContradictionFlaggedEvent] + * for the operator to eyeball. v1 is purely informational: there is no LLM judge and the caller + * emits the flag without ever halting or failing the stage. + * + * Namespace convention: distilled decision-journal lines are persisted into L3 by + * [ProjectMemoryService] under `turnId = "project:"` (trailing-`:` delimiter). This is + * the only decision-bearing L3 namespace that exists today, so [decisionNamespacePrefix] defaults + * to `"project:"` — a `startsWith` prefix, matching the trailing-`:` delimiter convention used by + * [L3RepoKnowledgeRetriever]'s `"repomap::"` filter. Hits are also constrained to PRIOR + * sessions (`entry.sessionId != sessionId`) so the architect never flags its own in-flight run. + */ +class ArchitectContradictionChecker( + private val embedder: Embedder, + private val l3MemoryStore: L3MemoryStore, + private val k: Int = DEFAULT_K, + private val scoreThreshold: Double = DEFAULT_SCORE_THRESHOLD, + private val decisionNamespacePrefix: String = DEFAULT_DECISION_NAMESPACE_PREFIX, +) { + /** + * @return a [PossibleContradictionFlaggedEvent] listing related prior decisions, or null when + * none clear the threshold. The CALLER emits it (display-only) — this never halts. + */ + suspend fun check( + sessionId: SessionId, + stageId: StageId, + decisionText: String, + ): PossibleContradictionFlaggedEvent? { + if (decisionText.isBlank()) return null + val vector = embedder.embed(decisionText) + val related = l3MemoryStore.query(L3Query(vector = vector, k = k * RETRIEVAL_OVERSAMPLE_FACTOR)) + .filter { it.entry.turnId.startsWith(decisionNamespacePrefix) } + .filter { it.entry.sessionId != sessionId } + .filter { it.score >= scoreThreshold } + .take(k) + .map { + RelatedDecision( + summary = it.entry.text, + score = it.score.toDouble(), + source = it.entry.turnId, + ) + } + if (related.isEmpty()) return null + return PossibleContradictionFlaggedEvent( + sessionId = sessionId, + stageId = stageId, + decisionSummary = decisionText, + related = related, + ) + } + + companion object { + const val DEFAULT_K = 5 + const val DEFAULT_SCORE_THRESHOLD = 0.75 + const val DEFAULT_DECISION_NAMESPACE_PREFIX = "project:" + private const val RETRIEVAL_OVERSAMPLE_FACTOR = 4 + } +} diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/memory/ArchitectContradictionCheckerTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/memory/ArchitectContradictionCheckerTest.kt new file mode 100644 index 00000000..36188d50 --- /dev/null +++ b/apps/server/src/test/kotlin/com/correx/apps/server/memory/ArchitectContradictionCheckerTest.kt @@ -0,0 +1,107 @@ +package com.correx.apps.server.memory + +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.inference.Embedder +import com.correx.core.router.l3.L3Hit +import com.correx.core.router.l3.L3MemoryEntry +import com.correx.core.router.l3.L3MemoryStore +import com.correx.core.router.l3.L3Query +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.util.UUID + +private class ContradictionOnesEmbedder(override val dimension: Int = 8) : Embedder { + override suspend fun embed(text: String): FloatArray = FloatArray(dimension) { 1f } +} + +/** Returns canned hits regardless of the query vector, so scores can be controlled precisely. */ +private class CannedL3MemoryStore(private val hits: List) : L3MemoryStore { + override suspend fun store(entry: L3MemoryEntry) = Unit + override suspend fun query(query: L3Query): List = hits.sortedByDescending { it.score }.take(query.k) + override suspend fun existsByTurnIdPrefix(prefix: String): Boolean = hits.any { it.entry.turnId.startsWith(prefix) } + override suspend fun close() = Unit +} + +class ArchitectContradictionCheckerTest { + + private val newSession = SessionId("new-session") + private val priorSession = SessionId("prior-session") + private val stageId = StageId("architect") + + private fun hit(text: String, score: Float, turnId: String, sessionId: SessionId = priorSession) = + L3Hit( + entry = L3MemoryEntry( + id = UUID.randomUUID().toString(), + sessionId = sessionId, + turnId = turnId, + text = text, + vector = FloatArray(8) { 1f }, + timestampMs = 0L, + ), + score = score, + ) + + @Test + fun `flags a related prior decision above threshold`() = runBlocking { + val store = CannedL3MemoryStore( + listOf(hit("Decided to use SQLite for the event store.", score = 0.9f, turnId = "project:/repo")), + ) + val checker = ArchitectContradictionChecker(ContradictionOnesEmbedder(), store) + + val flag = checker.check(newSession, stageId, "Use Postgres for the event store.") + + assertTrue(flag != null, "expected a flag") + assertEquals(newSession, flag!!.sessionId) + assertEquals(stageId, flag.stageId) + assertEquals("Use Postgres for the event store.", flag.decisionSummary) + assertEquals(1, flag.related.size) + val related = flag.related.single() + assertEquals("Decided to use SQLite for the event store.", related.summary) + assertEquals("project:/repo", related.source) + assertEquals(0.9, related.score, 1e-6) + } + + @Test + fun `returns null when there are no hits`() = runBlocking { + val checker = ArchitectContradictionChecker(ContradictionOnesEmbedder(), CannedL3MemoryStore(emptyList())) + + assertNull(checker.check(newSession, stageId, "Use Postgres for the event store.")) + } + + @Test + fun `returns null when all hits are below threshold`() = runBlocking { + val store = CannedL3MemoryStore( + listOf(hit("Loosely related prior note.", score = 0.5f, turnId = "project:/repo")), + ) + val checker = ArchitectContradictionChecker(ContradictionOnesEmbedder(), store, scoreThreshold = 0.75) + + assertNull(checker.check(newSession, stageId, "Use Postgres for the event store.")) + } + + @Test + fun `filters out hits outside the decision namespace`() = runBlocking { + val store = CannedL3MemoryStore( + listOf( + // Above threshold but a repo-map entry, not a decision — must be excluded. + hit("Foo.kt: ClassA, funcB", score = 0.95f, turnId = "repomap:/repo:abc"), + ), + ) + val checker = ArchitectContradictionChecker(ContradictionOnesEmbedder(), store) + + assertNull(checker.check(newSession, stageId, "Use Postgres for the event store.")) + } + + @Test + fun `filters out the architect's own in-flight session`() = runBlocking { + val store = CannedL3MemoryStore( + listOf(hit("Same-session decision.", score = 0.95f, turnId = "project:/repo", sessionId = newSession)), + ) + val checker = ArchitectContradictionChecker(ContradictionOnesEmbedder(), store) + + assertNull(checker.check(newSession, stageId, "Use Postgres for the event store.")) + } +} diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/ContradictionEvents.kt b/core/events/src/main/kotlin/com/correx/core/events/events/ContradictionEvents.kt new file mode 100644 index 00000000..aaf5b554 --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/events/ContradictionEvents.kt @@ -0,0 +1,33 @@ +package com.correx.core.events.events + +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * One prior decision/ADR surfaced as semantically near a new architect decision. + * [source] is the L3 entry's turnId (or a decision id) the line came from; [score] is the + * similarity of the prior decision to the new one. + */ +@Serializable +data class RelatedDecision( + val summary: String, + val score: Double, + val source: String, +) + +/** + * Display-only architect contradiction surfacing (BACKLOG §B-§4). Lists prior decisions + * semantically near the new one so the operator can spot a reversal. Non-blocking: this event + * only surfaces similarity candidates for an operator to eyeball — it never halts or fails a + * stage, and there is no LLM judge. + */ +@Serializable +@SerialName("PossibleContradictionFlagged") +data class PossibleContradictionFlaggedEvent( + val sessionId: SessionId, + val stageId: StageId, + val decisionSummary: String, + val related: List, +) : EventPayload diff --git a/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt b/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt index 32fb6e6a..ddf42d40 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt @@ -20,6 +20,7 @@ import com.correx.core.events.events.OperatorProfileBoundEvent import com.correx.core.events.events.ProjectProfileBoundEvent import com.correx.core.events.events.SessionWorkspaceBoundEvent import com.correx.core.events.events.ContextTruncatedEvent +import com.correx.core.events.events.PossibleContradictionFlaggedEvent import com.correx.core.events.events.EventPayload import com.correx.core.events.events.ExecutionPlanLockedEvent import com.correx.core.events.events.ExecutionPlanRejectedEvent @@ -135,6 +136,7 @@ val eventModule = SerializersModule { subclass(CritiqueOutcomeCorrelatedEvent::class) subclass(StageCheckpointPassedEvent::class) subclass(StageCheckpointFailedEvent::class) + subclass(PossibleContradictionFlaggedEvent::class) } } diff --git a/core/events/src/test/kotlin/com/correx/core/events/serialization/ContradictionEventSerializationTest.kt b/core/events/src/test/kotlin/com/correx/core/events/serialization/ContradictionEventSerializationTest.kt new file mode 100644 index 00000000..84fa47dd --- /dev/null +++ b/core/events/src/test/kotlin/com/correx/core/events/serialization/ContradictionEventSerializationTest.kt @@ -0,0 +1,36 @@ +package com.correx.core.events.serialization + +import com.correx.core.events.events.EventPayload +import com.correx.core.events.events.PossibleContradictionFlaggedEvent +import com.correx.core.events.events.RelatedDecision +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ContradictionEventSerializationTest { + + @Test + fun `PossibleContradictionFlaggedEvent round-trips as polymorphic EventPayload`() { + val sample: EventPayload = PossibleContradictionFlaggedEvent( + sessionId = SessionId("s"), + stageId = StageId("architect"), + decisionSummary = "Use Postgres for the event store.", + related = listOf( + RelatedDecision( + summary = "Decided to use SQLite for the event store.", + score = 0.91, + source = "project:/repo", + ), + ), + ) + val encoded = eventJson.encodeToString(EventPayload.serializer(), sample) + assertTrue( + encoded.contains("\"type\":\"PossibleContradictionFlagged\""), + "SerialName must be present: $encoded", + ) + val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded) + assertEquals(sample, decoded) + } +} From b098d870752f9723a97672aa41ffa4bee1a1d2af Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 20 Jun 2026 11:49:26 +0000 Subject: [PATCH 009/107] feat(research): dedicated SourceFetched + LowQualityExtraction events (D) - promote research fetch quality + content_sha256 from ToolExecutionCompletedEvent metadata to first-class SourceFetchedEvent / LowQualityExtractionEvent (registered + serialization test); existing metadata write kept intact (additive) - emitted from SandboxedToolExecutor on research-fetch tool completion, guarded on the url/content_sha256/quality markers; reuses the extractor's LOW_QUALITY marker Co-Authored-By: Claude Opus 4.8 --- .../events/events/ResearchSourceEvents.kt | 41 ++++++ .../events/serialization/Serialization.kt | 4 + .../ResearchSourceEventSerializationTest.kt | 47 +++++++ .../tools/SandboxedToolExecutor.kt | 40 ++++++ ...SandboxedToolExecutorResearchSourceTest.kt | 127 ++++++++++++++++++ 5 files changed, 259 insertions(+) create mode 100644 core/events/src/main/kotlin/com/correx/core/events/events/ResearchSourceEvents.kt create mode 100644 core/events/src/test/kotlin/com/correx/core/events/serialization/ResearchSourceEventSerializationTest.kt create mode 100644 infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/SandboxedToolExecutorResearchSourceTest.kt diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/ResearchSourceEvents.kt b/core/events/src/main/kotlin/com/correx/core/events/events/ResearchSourceEvents.kt new file mode 100644 index 00000000..48bfd0cb --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/events/ResearchSourceEvents.kt @@ -0,0 +1,41 @@ +package com.correx.core.events.events + +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * A research T2 fetch completed and produced content at [contentSha256] (research-workflow-spec §3/§5). + * + * Promotes the fetch-quality + content-hash that previously lived only inside + * [ToolExecutionCompletedEvent] metadata into a first-class event, so "what sources were fetched" + * is queryable and renderable directly from the journal. Additive: the metadata write is retained + * for existing consumers. [quality] mirrors the extractor's quality marker as recorded in metadata. + */ +@Serializable +@SerialName("SourceFetched") +data class SourceFetchedEvent( + val sessionId: SessionId, + val stageId: StageId, + val url: String, + val contentSha256: String, + val quality: String, + val byteCount: Int = 0, +) : EventPayload + +/** + * An extraction was retained but flagged low-quality — below the quality bar (research-workflow-spec §5) + * — for operator visibility. Emitted alongside [SourceFetchedEvent] when the fetch cleared rejection + * but fell under the minimum-content threshold (JS-rendered SPA, paywall); the content is still kept, + * the operator is simply warned that synthesis may want to route around it. + */ +@Serializable +@SerialName("LowQualityExtraction") +data class LowQualityExtractionEvent( + val sessionId: SessionId, + val stageId: StageId, + val url: String, + val contentSha256: String, + val reason: String, +) : EventPayload diff --git a/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt b/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt index ddf42d40..0c215e13 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt @@ -31,6 +31,7 @@ import com.correx.core.events.events.IdeaDiscardedEvent import com.correx.core.events.events.JournalCompactedEvent import com.correx.core.events.events.FileWrittenEvent import com.correx.core.events.events.L3MemoryRetrievedEvent +import com.correx.core.events.events.LowQualityExtractionEvent import com.correx.core.events.events.InferenceCompletedEvent import com.correx.core.events.events.InitialIntentEvent import com.correx.core.events.events.InferenceFailedEvent @@ -46,6 +47,7 @@ import com.correx.core.events.events.RepoMapComputedEvent import com.correx.core.events.events.RetryAttemptedEvent import com.correx.core.events.events.WorkspaceStateObservedEvent import com.correx.core.events.events.RiskAssessedEvent +import com.correx.core.events.events.SourceFetchedEvent import com.correx.core.events.events.StageCheckpointFailedEvent import com.correx.core.events.events.StageCheckpointPassedEvent import com.correx.core.events.events.StageCompletedEvent @@ -137,6 +139,8 @@ val eventModule = SerializersModule { subclass(StageCheckpointPassedEvent::class) subclass(StageCheckpointFailedEvent::class) subclass(PossibleContradictionFlaggedEvent::class) + subclass(SourceFetchedEvent::class) + subclass(LowQualityExtractionEvent::class) } } diff --git a/core/events/src/test/kotlin/com/correx/core/events/serialization/ResearchSourceEventSerializationTest.kt b/core/events/src/test/kotlin/com/correx/core/events/serialization/ResearchSourceEventSerializationTest.kt new file mode 100644 index 00000000..54263f1a --- /dev/null +++ b/core/events/src/test/kotlin/com/correx/core/events/serialization/ResearchSourceEventSerializationTest.kt @@ -0,0 +1,47 @@ +package com.correx.core.events.serialization + +import com.correx.core.events.events.EventPayload +import com.correx.core.events.events.LowQualityExtractionEvent +import com.correx.core.events.events.SourceFetchedEvent +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ResearchSourceEventSerializationTest { + + private val sessionId = SessionId("s") + private val stageId = StageId("st") + + @Test + fun `SourceFetchedEvent round-trips as polymorphic EventPayload`() { + val sample: EventPayload = SourceFetchedEvent( + sessionId = sessionId, + stageId = stageId, + url = "https://example.com/a", + contentSha256 = "abc123", + quality = "OK", + byteCount = 4096, + ) + val encoded = eventJson.encodeToString(EventPayload.serializer(), sample) + assertTrue(encoded.contains("\"type\":\"SourceFetched\""), "SerialName must be present: $encoded") + val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded) + assertEquals(sample, decoded) + } + + @Test + fun `LowQualityExtractionEvent round-trips as polymorphic EventPayload`() { + val sample: EventPayload = LowQualityExtractionEvent( + sessionId = sessionId, + stageId = stageId, + url = "https://example.com/spa", + contentSha256 = "def456", + reason = "extracted 12 chars, below minimum 200", + ) + val encoded = eventJson.encodeToString(EventPayload.serializer(), sample) + assertTrue(encoded.contains("\"type\":\"LowQualityExtraction\""), "SerialName must be present: $encoded") + val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded) + assertEquals(sample, decoded) + } +} diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/SandboxedToolExecutor.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/SandboxedToolExecutor.kt index 33927af4..f4d8cfd8 100644 --- a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/SandboxedToolExecutor.kt +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/SandboxedToolExecutor.kt @@ -4,12 +4,15 @@ import com.correx.core.artifactstore.ArtifactStore import com.correx.core.events.EventDispatcher import com.correx.core.events.events.EventPayload import com.correx.core.events.events.FileWrittenEvent +import com.correx.core.events.events.LowQualityExtractionEvent +import com.correx.core.events.events.SourceFetchedEvent import com.correx.core.events.events.ToolExecutionCompletedEvent import com.correx.core.events.events.ToolExecutionFailedEvent import com.correx.core.events.events.ToolExecutionStartedEvent import com.correx.core.events.events.ToolReceipt import com.correx.core.events.events.ToolRequest import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId import com.correx.core.events.types.ToolInvocationId import com.correx.core.tools.contract.FileAffectingTool import com.correx.core.tools.contract.Tool @@ -82,6 +85,7 @@ class SandboxedToolExecutor( restoreOrClean(backupMap, success = true) cleanWorkingDir(workingDir) emitCompleted(sessionId, invocationId, toolName, result, tool, affectedPaths, durationMs(), diff) + emitResearchSourceEvents(sessionId, request.stageId, result) emitFileMutations(sessionId, invocationId, affectedPaths, preImages) result } @@ -183,6 +187,37 @@ class SandboxedToolExecutor( ) } + /** + * Promotes the research fetch's quality + content-hash (BACKLOG §D) from the generic + * [ToolExecutionCompletedEvent] metadata into first-class events, additively: the metadata write + * above is left intact for existing consumers. Only fires for results that carry the fetch + * markers ([url] + [content_sha256] + [quality]), so non-fetch tools are untouched. The + * low-quality threshold reuses the extractor's own marker — [ExtractionQuality.LOW_QUALITY], whose + * name is what [WebFetchTool] writes into `quality` — rather than inventing a new policy here. + */ + private suspend fun emitResearchSourceEvents( + sessionId: SessionId, + stageId: StageId, + result: ToolResult.Success, + ) { + val md = result.metadata + val url = md["url"] + val contentSha256 = md["content_sha256"] + val quality = md["quality"] + if (url == null || contentSha256 == null || quality == null) return + val byteCount = md["fetched_bytes"]?.toIntOrNull() ?: 0 + + emit(sessionId, SourceFetchedEvent(sessionId, stageId, url, contentSha256, quality, byteCount)) + + // Source of truth for the bar is the extractor (ExtractionQuality.LOW_QUALITY); its enum name + // is what WebFetchTool records in `quality`. Keep the marker as a literal to avoid a core -> + // infrastructure dependency inversion. + if (quality == LOW_QUALITY_MARKER) { + val reason = "extraction quality below the minimum-content bar ($byteCount bytes fetched)" + emit(sessionId, LowQualityExtractionEvent(sessionId, stageId, url, contentSha256, reason)) + } + } + @Suppress("MaxLineLength") private fun computeDiff(affectedPaths: Set, backupMap: Map): String? { val diffs = mutableListOf() @@ -266,4 +301,9 @@ class SandboxedToolExecutor( ) } } + + private companion object { + /** Mirrors `ExtractionQuality.LOW_QUALITY.name` — the marker WebFetchTool writes into `quality`. */ + const val LOW_QUALITY_MARKER = "LOW_QUALITY" + } } diff --git a/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/SandboxedToolExecutorResearchSourceTest.kt b/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/SandboxedToolExecutorResearchSourceTest.kt new file mode 100644 index 00000000..3e1e7195 --- /dev/null +++ b/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/SandboxedToolExecutorResearchSourceTest.kt @@ -0,0 +1,127 @@ +package com.correx.infrastructure.tools + +import com.correx.core.approvals.Tier +import com.correx.core.events.EventDispatcher +import com.correx.core.events.events.EventPayload +import com.correx.core.events.events.LowQualityExtractionEvent +import com.correx.core.events.events.NewEvent +import com.correx.core.events.events.SourceFetchedEvent +import com.correx.core.events.events.StoredEvent +import com.correx.core.events.events.ToolRequest +import com.correx.core.events.stores.EventStore +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.events.types.ToolInvocationId +import com.correx.core.tools.contract.ParamRole +import com.correx.core.tools.contract.Tool +import com.correx.core.tools.contract.ToolCapability +import com.correx.core.tools.contract.ToolExecutor +import com.correx.core.tools.contract.ToolResult +import com.correx.core.tools.contract.ValidationResult +import com.correx.core.tools.registry.ToolRegistry +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.JsonObject +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.nio.file.Files + +class SandboxedToolExecutorResearchSourceTest { + + private class CapturingEventStore : EventStore { + val payloads = mutableListOf() + override suspend fun append(event: NewEvent): StoredEvent { + payloads += event.payload + val seq = payloads.size.toLong() + return StoredEvent(event.metadata, seq, seq, event.payload) + } + override suspend fun appendAll(events: List): List = events.map { append(it) } + override fun read(sessionId: SessionId): List = emptyList() + override fun readFrom(sessionId: SessionId, fromSequence: Long): List = emptyList() + override fun lastSequence(sessionId: SessionId): Long? = null + override fun subscribe(sessionId: SessionId): Flow = emptyFlow() + override fun subscribeAll(): Flow = emptyFlow() + override suspend fun lastGlobalSequence(): Long = payloads.size.toLong() + override fun allEvents(): Sequence = emptySequence() + override fun allSessionIds(): Set = emptySet() + } + + private class SingleToolRegistry(private val tool: Tool) : ToolRegistry { + override fun resolve(name: String): Tool? = if (name == tool.name) tool else null + override fun all(): List = listOf(tool) + } + + /** Stands in for a research fetch: returns the same metadata shape WebFetchTool emits. */ + private class FakeFetchTool(private val metadata: Map) : Tool, ToolExecutor { + override val name: String = "web_fetch" + override val description: String = "fake" + override val tier: Tier = Tier.T2 + override val requiredCapabilities: Set = setOf(ToolCapability.NETWORK_ACCESS) + override val paramRoles: Map = mapOf("url" to ParamRole.NETWORK_TARGET) + override val parametersSchema: JsonObject = JsonObject(emptyMap()) + override fun validateRequest(request: ToolRequest): ValidationResult = ValidationResult.Valid + override suspend fun execute(request: ToolRequest): ToolResult = + ToolResult.Success(request.invocationId, output = "md", metadata = metadata) + } + + private fun request() = ToolRequest( + ToolInvocationId("inv"), SessionId("s"), StageId("st"), "web_fetch", + mapOf("url" to "https://example.com/a"), + ) + + private fun runFetch(metadata: Map): CapturingEventStore { + val tool = FakeFetchTool(metadata) + val events = CapturingEventStore() + val exec = SandboxedToolExecutor( + delegate = tool, + registry = SingleToolRegistry(tool), + eventDispatcher = EventDispatcher(events), + workDir = Files.createTempDirectory("sbx-research"), + ) + runBlocking { exec.execute(request()) } + return events + } + + private fun fetchMetadata(quality: String) = mapOf( + "url" to "https://example.com/a", + "content_type" to "text/html", + "content_sha256" to "deadbeef", + "fetched_bytes" to "4096", + "extractor_version" to "html-md-1", + "quality" to quality, + ) + + @Test + fun `ok fetch emits SourceFetchedEvent and no low-quality event`() { + val events = runFetch(fetchMetadata("OK")) + + val fetched = events.payloads.filterIsInstance().single() + assertEquals("https://example.com/a", fetched.url) + assertEquals("deadbeef", fetched.contentSha256) + assertEquals("OK", fetched.quality) + assertEquals(4096, fetched.byteCount) + assertTrue(events.payloads.filterIsInstance().isEmpty()) + } + + @Test + fun `low-quality fetch also emits LowQualityExtractionEvent`() { + val events = runFetch(fetchMetadata("LOW_QUALITY")) + + assertEquals(1, events.payloads.filterIsInstance().size) + val low = events.payloads.filterIsInstance().single() + assertEquals("https://example.com/a", low.url) + assertEquals("deadbeef", low.contentSha256) + assertTrue(low.reason.isNotBlank()) + } + + @Test + fun `non-fetch tool result emits no research source events`() { + // No url/content_sha256/quality markers → the promotion path must stay silent. + val events = runFetch(mapOf("some" to "metadata")) + + assertTrue(events.payloads.filterIsInstance().isEmpty()) + assertTrue(events.payloads.filterIsInstance().isEmpty()) + } +} From 18bde7ca384306bdec48cda5670b04578cd852f6 Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 20 Jun 2026 11:53:07 +0000 Subject: [PATCH 010/107] docs(backlog): archive 9 resolved tracks to RETRO.md + refile follow-ups Co-Authored-By: Claude Opus 4.8 --- BACKLOG.md | 19 +++++++++++++++++++ RETRO.md | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 RETRO.md diff --git a/BACKLOG.md b/BACKLOG.md index df1c3511..21e863a7 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -21,6 +21,25 @@ against current code before acting (memory is point-in-time). --- +## ✅ RESOLVED 2026-06-20 → see `RETRO.md` (branch `feat/backlog-burndown`) + +Built + unit-verified this pass (full detail + commit table in `RETRO.md`). The +matching bullets below are superseded; only the noted follow-ups remain live: + +- **G** turnId `/repo`↔`/repo2` collision + WorkspaceStateProbe newline fingerprint — `784accb` +- **I** `.cs`/Godot-Mono symbols — `f715ffa`; structural `.tscn`/`.tres` validator — `0e251d6` +- **A §4** EventStore latency probe (wired) + llama-server probe — `266bbf0` *(follow-up: llama probe live wiring)* +- **B §6 + C-A3** `CritiqueFinding` type + calibration projection — `f783eed` *(follow-up: `CritiqueOutcomeCorrelatedEvent` producer)* +- **C-A1** brief echo-back gate — `1df7af5` *(follow-up: `planner.md` brief_echo + `role_pipeline` metadata to activate)* +- **C-A2** stage-level plan checkpointing — `1a1b5cc` +- **B §4** architect contradiction-check (display-only) — `eae0a0c` *(follow-up: decision emit hook + in-session L3 embedding)* +- **D** dedicated `SourceFetched`/`LowQualityExtraction` events — `b098d87` + +**Not yet started (verifiable, Kotlin):** B §5 reviewer static-first infra; D batch fetch-approval + dynamic egress allowlist; B §2 plan-derived diff manifest; G narration-lane lag; H freestyle follow-ups. +**Blocked in this sandbox:** E tui-go items (Go toolchain absent); F live-QA gates + D §6 web approval client (need local model / SearXNG / network / GPU). + +--- + ## A. Observability spec — `docs/plans/2026-06-13-observability-spec.md` Metrics + CLI + TUI pane and the health backbone are shipped (fe94140, 4f6bfa8, diff --git a/RETRO.md b/RETRO.md new file mode 100644 index 00000000..bd54040d --- /dev/null +++ b/RETRO.md @@ -0,0 +1,33 @@ +# CORREX — RETRO (archive of resolved BACKLOG items) + +Counterpart to `BACKLOG.md` (the live outstanding list). Items here are **built and +unit-verified** (Gradle green). Where a live-QA gate applies, that's called out as a +follow-up — this sandbox has no local model / SearXNG / GPU / network, so live-QA +could not be run here. + +--- + +## 2026-06-20 — backlog burndown (branch `feat/backlog-burndown`) + +Nine tracks, each compiled + unit-tested green before commit. + +| Commit | Track | BACKLOG ref | +|--------|-------|-------------| +| `f783eed` | `CritiqueFinding`/`CritiqueSeverity`/`CritiqueRole` type + `CritiqueOutcomeCorrelatedEvent` + `core/critique` calibration projection (state/reducer/projector) | B §6 + C-A3 | +| `784accb` | `repomap:` turnId namespace requires trailing `:` (no `/repo`↔`/repo2` collision; L3RepoKnowledgeRetriever filter + ProjectMemoryService write) + WorkspaceStateProbe newline fingerprint | G | +| `266bbf0` | `EventStoreLatencyProbe` (wired) + `LlamaServerHealthProbe` (+ injectable `LlamaLivenessClient`) + `HealthConfig.eventStoreWarnLatencyMs` | A §4 | +| `f715ffa` | `.cs` / Godot-Mono type symbols in `RepoMapIndexer.SYMBOL_PATTERNS` | I | +| `0e251d6` | `SceneValidator` — structural `.tscn`/`.tres` (header well-formedness, ExtResource/SubResource id resolution, node parent integrity) | I | +| `1df7af5` | Brief echo-back gate: `BriefEchoMismatchEvent` + `BriefEchoComparator`/`BriefEchoExtractor` + `checkBriefEcho` orchestrator gate | C-A1 | +| `1a1b5cc` | Stage-level plan checkpointing: `StageCheckpointPassed/FailedEvent` + `StageCheckpointReconciler` + `emitStageCheckpoint` (gated on a locked plan) | C-A2 | +| `eae0a0c` | Architect contradiction-check (display-only): `PossibleContradictionFlaggedEvent` + L3-backed `ArchitectContradictionChecker` | B §4 | +| `b098d87` | Dedicated `SourceFetchedEvent` / `LowQualityExtractionEvent` (promoted from `ToolExecutionCompletedEvent` metadata; additive), emitted from `SandboxedToolExecutor` | D | + +### Follow-ups created this pass (refiled into BACKLOG) +- **C-A1 activation** — `planner.md` must emit a `brief_echo` block and `role_pipeline.toml` must set `briefEcho="true"` / `briefEchoSource` for the gate to fire live (model-dependent → live-QA). +- **C-A3 producer** — nothing emits `CritiqueOutcomeCorrelatedEvent` yet; the calibration projection has no live feed until a reviewer-loop producer records finding outcomes. +- **B §4 wiring** — `ArchitectContradictionChecker` is unwired: needs an architect-decision emit hook on the server side, and prior decisions embedded into L3 in-session (today they only land at session end). +- **A §4 llama wiring** — `LlamaServerHealthProbe` is unregistered: an `HttpClient` + tokens/sec telemetry aren't exposed to the health-monitor scope yet (`// TODO(wiring)` in `Main.kt`). + +### Environment note +- A `~/.gradle/gradle.properties` machine-local override was added on this box (`-Xmx768m`, `parallel=false`, kotlin daemon `-Xmx640m`) because the repo's `-Xmx4G` OOM-killed the foreground process on a 1.9 GB machine. **Not committed** — repo defaults intact for higher-RAM machines. From 027ff1f6ff33604bef365163a0d319e8cd507f59 Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 20 Jun 2026 13:14:48 +0000 Subject: [PATCH 011/107] feat(toolintent): dynamic per-session egress allowlist (D) - EgressHostsGrantedEvent (registered + serialization test): additive per-session egress grants (e.g. an approved research source list) - EgressAllowlist pure union helper + EgressAllowlistProjection (folds grants per session); NetworkHostRule delegates to the union, preserving exact/suffix semantics - rule not yet fed the session set live (ToolCallAssessmentInput has no sessionId); precise TODO(wiring) left. batch fetch-approval skipped (needs approval-flow surgery) Co-Authored-By: Claude Opus 4.8 --- .../correx/core/events/events/EgressEvents.kt | 21 ++++++ .../events/serialization/Serialization.kt | 2 + .../projections/EgressAllowlistProjection.kt | 29 ++++++++ .../EgressEventSerializationTest.kt | 24 +++++++ .../EgressAllowlistProjectionTest.kt | 68 +++++++++++++++++++ .../core/toolintent/rules/EgressAllowlist.kt | 32 +++++++++ .../core/toolintent/rules/NetworkHostRule.kt | 11 ++- .../core/toolintent/EgressAllowlistTest.kt | 52 ++++++++++++++ 8 files changed, 238 insertions(+), 1 deletion(-) create mode 100644 core/events/src/main/kotlin/com/correx/core/events/events/EgressEvents.kt create mode 100644 core/events/src/main/kotlin/com/correx/core/sessions/projections/EgressAllowlistProjection.kt create mode 100644 core/events/src/test/kotlin/com/correx/core/events/serialization/EgressEventSerializationTest.kt create mode 100644 core/events/src/test/kotlin/com/correx/core/sessions/projections/EgressAllowlistProjectionTest.kt create mode 100644 core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/EgressAllowlist.kt create mode 100644 core/toolintent/src/test/kotlin/com/correx/core/toolintent/EgressAllowlistTest.kt diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/EgressEvents.kt b/core/events/src/main/kotlin/com/correx/core/events/events/EgressEvents.kt new file mode 100644 index 00000000..bd389e1c --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/events/EgressEvents.kt @@ -0,0 +1,21 @@ +package com.correx.core.events.events + +import com.correx.core.events.types.SessionId +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Grants additional egress [hosts] for this session (e.g. the hosts drawn from an approved + * research source list). Additive to the static `networkAllowedHosts` allow-list: a host is + * permitted if it matches the static list OR any host granted to the session, so egress can be + * widened per-session without loosening the global default. Host matching honours the same + * exact-or-suffix semantics the static allow-list uses (a granted "example.com" covers + * "api.example.com"). [reason] is free-form operator context (e.g. which source list was approved). + */ +@Serializable +@SerialName("EgressHostsGranted") +data class EgressHostsGrantedEvent( + val sessionId: SessionId, + val hosts: Set, + val reason: String = "", +) : EventPayload diff --git a/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt b/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt index 0c215e13..d9b17031 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt @@ -21,6 +21,7 @@ import com.correx.core.events.events.ProjectProfileBoundEvent import com.correx.core.events.events.SessionWorkspaceBoundEvent import com.correx.core.events.events.ContextTruncatedEvent import com.correx.core.events.events.PossibleContradictionFlaggedEvent +import com.correx.core.events.events.EgressHostsGrantedEvent import com.correx.core.events.events.EventPayload import com.correx.core.events.events.ExecutionPlanLockedEvent import com.correx.core.events.events.ExecutionPlanRejectedEvent @@ -141,6 +142,7 @@ val eventModule = SerializersModule { subclass(PossibleContradictionFlaggedEvent::class) subclass(SourceFetchedEvent::class) subclass(LowQualityExtractionEvent::class) + subclass(EgressHostsGrantedEvent::class) } } diff --git a/core/events/src/main/kotlin/com/correx/core/sessions/projections/EgressAllowlistProjection.kt b/core/events/src/main/kotlin/com/correx/core/sessions/projections/EgressAllowlistProjection.kt new file mode 100644 index 00000000..61710af3 --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/sessions/projections/EgressAllowlistProjection.kt @@ -0,0 +1,29 @@ +package com.correx.core.sessions.projections + +import com.correx.core.events.events.EgressHostsGrantedEvent +import com.correx.core.events.events.StoredEvent +import com.correx.core.events.types.SessionId + +/** + * Folds the additional egress hosts granted to one session. State is the running union of every + * [EgressHostsGrantedEvent.hosts] emitted for [sessionId]; grants are additive (set union) and + * unrelated events — including grants for other sessions — are ignored. The resulting + * `Set` is the per-session input to + * [com.correx.core.toolintent.rules.EgressAllowlist.isAllowed], unioned with the static + * `networkAllowedHosts` at the network gate. + */ +class EgressAllowlistProjection( + private val sessionId: SessionId, +) : Projection> { + + override fun initial(): Set = emptySet() + + override fun apply(state: Set, event: StoredEvent): Set { + val payload = event.payload + return if (payload is EgressHostsGrantedEvent && payload.sessionId == sessionId) { + state + payload.hosts + } else { + state + } + } +} diff --git a/core/events/src/test/kotlin/com/correx/core/events/serialization/EgressEventSerializationTest.kt b/core/events/src/test/kotlin/com/correx/core/events/serialization/EgressEventSerializationTest.kt new file mode 100644 index 00000000..3ecbac76 --- /dev/null +++ b/core/events/src/test/kotlin/com/correx/core/events/serialization/EgressEventSerializationTest.kt @@ -0,0 +1,24 @@ +package com.correx.core.events.serialization + +import com.correx.core.events.events.EgressHostsGrantedEvent +import com.correx.core.events.events.EventPayload +import com.correx.core.events.types.SessionId +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class EgressEventSerializationTest { + + @Test + fun `EgressHostsGrantedEvent round-trips as polymorphic EventPayload`() { + val sample: EventPayload = EgressHostsGrantedEvent( + sessionId = SessionId("s"), + hosts = setOf("example.com", "docs.rust-lang.org"), + reason = "approved research source list", + ) + val encoded = eventJson.encodeToString(EventPayload.serializer(), sample) + assertTrue(encoded.contains("\"type\":\"EgressHostsGranted\""), "SerialName must be present: $encoded") + val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded) + assertEquals(sample, decoded) + } +} diff --git a/core/events/src/test/kotlin/com/correx/core/sessions/projections/EgressAllowlistProjectionTest.kt b/core/events/src/test/kotlin/com/correx/core/sessions/projections/EgressAllowlistProjectionTest.kt new file mode 100644 index 00000000..7783fb62 --- /dev/null +++ b/core/events/src/test/kotlin/com/correx/core/sessions/projections/EgressAllowlistProjectionTest.kt @@ -0,0 +1,68 @@ +package com.correx.core.sessions.projections + +import com.correx.core.events.events.EgressHostsGrantedEvent +import com.correx.core.events.events.EventMetadata +import com.correx.core.events.events.EventPayload +import com.correx.core.events.events.InitialIntentEvent +import com.correx.core.events.events.StoredEvent +import com.correx.core.events.types.EventId +import com.correx.core.events.types.SessionId +import kotlinx.datetime.Instant +import kotlin.test.Test +import kotlin.test.assertEquals + +class EgressAllowlistProjectionTest { + + private val sessionId = SessionId("s1") + private val projection = EgressAllowlistProjection(sessionId) + + private fun stored(payload: EventPayload, eventId: String = "e1", session: SessionId = sessionId) = + StoredEvent( + metadata = EventMetadata( + eventId = EventId(eventId), + sessionId = session, + timestamp = Instant.parse("2026-01-01T00:00:00Z"), + schemaVersion = 1, + causationId = null, + correlationId = null, + ), + sequence = 1L, + sessionSequence = 1L, + payload = payload, + ) + + private fun fold(vararg events: StoredEvent): Set = + events.fold(projection.initial()) { acc, e -> projection.apply(acc, e) } + + @Test + fun `initial state is empty`() { + assertEquals(emptySet(), projection.initial()) + } + + @Test + fun `grants fold into a union`() { + val result = fold( + stored(EgressHostsGrantedEvent(sessionId, setOf("a.com", "b.com")), "e1"), + stored(EgressHostsGrantedEvent(sessionId, setOf("b.com", "c.com")), "e2"), + ) + assertEquals(setOf("a.com", "b.com", "c.com"), result) + } + + @Test + fun `grants for other sessions are ignored`() { + val result = fold( + stored(EgressHostsGrantedEvent(sessionId, setOf("mine.com")), "e1"), + stored(EgressHostsGrantedEvent(SessionId("other"), setOf("theirs.com")), "e2"), + ) + assertEquals(setOf("mine.com"), result) + } + + @Test + fun `unrelated events are ignored`() { + val result = fold( + stored(InitialIntentEvent(sessionId, "do a thing"), "e1"), + stored(EgressHostsGrantedEvent(sessionId, setOf("kept.com")), "e2"), + ) + assertEquals(setOf("kept.com"), result) + } +} diff --git a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/EgressAllowlist.kt b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/EgressAllowlist.kt new file mode 100644 index 00000000..5a00c415 --- /dev/null +++ b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/EgressAllowlist.kt @@ -0,0 +1,32 @@ +package com.correx.core.toolintent.rules + +/** + * Pure, stateless host allow-list matching for network egress. The effective allow-list is the + * union of the static `networkAllowedHosts` and any hosts granted to the active session (see + * [com.correx.core.events.events.EgressHostsGrantedEvent]): a host is allowed if it matches the + * static set OR the session set. Matching honours the exact-or-suffix semantics + * [com.correx.core.toolintent.rules.NetworkHostRule] uses — a configured "example.com" covers + * "api.example.com" — and never narrows it; the session set can only widen what is allowed. + * + * An empty union (no static and no session hosts) means "no allow-list configured", which the + * caller treats as allow-all, mirroring the rule's existing behaviour. + */ +object EgressAllowlist { + + /** + * True if [host] is permitted by the union of [staticHosts] and [sessionHosts]. Both sets are + * normalised to lower-case; [host] is matched case-insensitively. An empty union allows any host. + */ + fun isAllowed(host: String, staticHosts: Set, sessionHosts: Set): Boolean { + val target = host.lowercase() + val union = staticHosts.asSequence() + sessionHosts.asSequence() + var sawAny = false + for (allowed in union) { + sawAny = true + val a = allowed.lowercase() + if (target == a || target.endsWith(".$a")) return true + } + // No allow-list configured at all → allow-all (matches NetworkHostRule's empty-list behaviour). + return !sawAny + } +} diff --git a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/NetworkHostRule.kt b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/NetworkHostRule.kt index 5674c9fb..0b01cb9b 100644 --- a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/NetworkHostRule.kt +++ b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/NetworkHostRule.kt @@ -38,7 +38,16 @@ class NetworkHostRule( for (host in hosts(input)) { val denyHit = denied.any { host == it || host.endsWith(".$it") } - val allowHit = allowed.isEmpty() || allowed.any { host == it || host.endsWith(".$it") } + // TODO(wiring): pass the active session's granted egress hosts here instead of emptySet(). + // ToolCallAssessmentInput carries no sessionId / session-state today, and this rule is + // constructed once at boot from static config (apps/server Main.kt), so it cannot resolve + // per-session grants. To go live: (1) add `sessionId: SessionId` (and/or the effective + // session egress set) to ToolCallAssessmentInput where it is built per call; (2) fold + // EgressHostsGrantedEvent for that session via EgressAllowlistProjection into a + // Set; (3) substitute that set for `emptySet()` below. The union helper and the + // projection are already in place — only the input plumbing is missing. + val sessionHosts: Set = emptySet() + val allowHit = EgressAllowlist.isAllowed(host, allowed, sessionHosts) observations += ToolCallObservation( ruleCode = RULE_CODE, facts = mapOf("host" to host, "denied" to denyHit.toString(), "allowed" to allowHit.toString()), diff --git a/core/toolintent/src/test/kotlin/com/correx/core/toolintent/EgressAllowlistTest.kt b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/EgressAllowlistTest.kt new file mode 100644 index 00000000..dd2515c2 --- /dev/null +++ b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/EgressAllowlistTest.kt @@ -0,0 +1,52 @@ +package com.correx.core.toolintent + +import com.correx.core.toolintent.rules.EgressAllowlist +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class EgressAllowlistTest { + + @Test + fun `host in static set only is allowed`() { + assertTrue(EgressAllowlist.isAllowed("good.com", setOf("good.com"), emptySet())) + } + + @Test + fun `host in session set only is allowed`() { + assertTrue(EgressAllowlist.isAllowed("granted.com", emptySet(), setOf("granted.com"))) + } + + @Test + fun `host in neither set is denied when an allow-list exists`() { + assertFalse(EgressAllowlist.isAllowed("other.com", setOf("good.com"), setOf("granted.com"))) + } + + @Test + fun `empty union allows any host`() { + assertTrue(EgressAllowlist.isAllowed("anywhere.example.com", emptySet(), emptySet())) + } + + @Test + fun `suffix match is honoured for static hosts`() { + assertTrue(EgressAllowlist.isAllowed("api.good.com", setOf("good.com"), emptySet())) + } + + @Test + fun `suffix match is honoured for session hosts`() { + assertTrue(EgressAllowlist.isAllowed("docs.granted.com", emptySet(), setOf("granted.com"))) + } + + @Test + fun `suffix match does not leak across unrelated domains`() { + // "evilgood.com" must NOT match a configured "good.com" — only true subdomains. + assertFalse(EgressAllowlist.isAllowed("evilgood.com", setOf("good.com"), emptySet())) + } + + @Test + fun `matching is case-insensitive`() { + assertTrue(EgressAllowlist.isAllowed("API.Good.COM", setOf("good.com"), emptySet())) + assertTrue(EgressAllowlist.isAllowed("api.good.com", setOf("GOOD.COM"), emptySet())) + assertTrue(EgressAllowlist.isAllowed("api.granted.com", emptySet(), setOf("Granted.Com"))) + } +} From eb9b1aee65457eb74ee420000a98ceb376acd750 Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 20 Jun 2026 13:15:44 +0000 Subject: [PATCH 012/107] docs(backlog): record egress allowlist (027ff1f) in RETRO Co-Authored-By: Claude Opus 4.8 --- BACKLOG.md | 4 ++-- RETRO.md | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/BACKLOG.md b/BACKLOG.md index 21e863a7..1b94bcbb 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -33,9 +33,9 @@ matching bullets below are superseded; only the noted follow-ups remain live: - **C-A1** brief echo-back gate — `1df7af5` *(follow-up: `planner.md` brief_echo + `role_pipeline` metadata to activate)* - **C-A2** stage-level plan checkpointing — `1a1b5cc` - **B §4** architect contradiction-check (display-only) — `eae0a0c` *(follow-up: decision emit hook + in-session L3 embedding)* -- **D** dedicated `SourceFetched`/`LowQualityExtraction` events — `b098d87` +- **D** dedicated `SourceFetched`/`LowQualityExtraction` events — `b098d87`; dynamic per-session egress allowlist — `027ff1f` *(follow-up: thread sessionId into `NetworkHostRule`; batch fetch-approval still unbuilt)* -**Not yet started (verifiable, Kotlin):** B §5 reviewer static-first infra; D batch fetch-approval + dynamic egress allowlist; B §2 plan-derived diff manifest; G narration-lane lag; H freestyle follow-ups. +**Not yet started (verifiable, Kotlin):** B §5 reviewer static-first infra; D batch fetch-approval; B §2 plan-derived diff manifest; G narration-lane lag; H freestyle follow-ups. **Blocked in this sandbox:** E tui-go items (Go toolchain absent); F live-QA gates + D §6 web approval client (need local model / SearXNG / network / GPU). --- diff --git a/RETRO.md b/RETRO.md index bd54040d..3c06458e 100644 --- a/RETRO.md +++ b/RETRO.md @@ -22,12 +22,14 @@ Nine tracks, each compiled + unit-tested green before commit. | `1a1b5cc` | Stage-level plan checkpointing: `StageCheckpointPassed/FailedEvent` + `StageCheckpointReconciler` + `emitStageCheckpoint` (gated on a locked plan) | C-A2 | | `eae0a0c` | Architect contradiction-check (display-only): `PossibleContradictionFlaggedEvent` + L3-backed `ArchitectContradictionChecker` | B §4 | | `b098d87` | Dedicated `SourceFetchedEvent` / `LowQualityExtractionEvent` (promoted from `ToolExecutionCompletedEvent` metadata; additive), emitted from `SandboxedToolExecutor` | D | +| `027ff1f` | Dynamic per-session egress allowlist: `EgressHostsGrantedEvent` + `EgressAllowlist` union helper + `EgressAllowlistProjection`; `NetworkHostRule` delegates to the union | D | ### Follow-ups created this pass (refiled into BACKLOG) - **C-A1 activation** — `planner.md` must emit a `brief_echo` block and `role_pipeline.toml` must set `briefEcho="true"` / `briefEchoSource` for the gate to fire live (model-dependent → live-QA). - **C-A3 producer** — nothing emits `CritiqueOutcomeCorrelatedEvent` yet; the calibration projection has no live feed until a reviewer-loop producer records finding outcomes. - **B §4 wiring** — `ArchitectContradictionChecker` is unwired: needs an architect-decision emit hook on the server side, and prior decisions embedded into L3 in-session (today they only land at session end). - **A §4 llama wiring** — `LlamaServerHealthProbe` is unregistered: an `HttpClient` + tokens/sec telemetry aren't exposed to the health-monitor scope yet (`// TODO(wiring)` in `Main.kt`). +- **D egress wiring** — `NetworkHostRule` consults the session set via a pure helper but is fed `emptySet()`: `ToolCallAssessmentInput` carries no `sessionId`, so going live needs session context threaded into the assessment input (`// TODO(wiring)` in `NetworkHostRule.kt`). Batch fetch-approval (approve a source list → one `EgressHostsGrantedEvent`) still unbuilt — needs approval-flow surgery. ### Environment note - A `~/.gradle/gradle.properties` machine-local override was added on this box (`-Xmx768m`, `parallel=false`, kotlin daemon `-Xmx640m`) because the repo's `-Xmx4G` OOM-killed the foreground process on a 1.9 GB machine. **Not committed** — repo defaults intact for higher-RAM machines. From 447fc7a43e28a3d1ce0d2af0d4c43aefb6a2c833 Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 20 Jun 2026 15:36:06 +0000 Subject: [PATCH 013/107] =?UTF-8?q?feat(kernel):=20reviewer=20static-first?= =?UTF-8?q?=20infra=20(B=C2=A75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - StaticFinding/StaticFindingSeverity + StaticFindingsRecordedEvent (registered + test) - StaticAnalysisRunner over an injectable CommandRunner; parses compiler/ktlint/detekt finding formats (lenient) into StaticFindings - excludeStaticFindingsFromReview pure filter, live-wired into SessionOrchestrator context assembly so static-tool findings are kept out of the reviewer's context - static_check pipeline stage left as a documented TODO (needs an event-emitting deterministic stage-type seam that doesn't exist yet) Co-Authored-By: Claude Opus 4.8 --- .../events/events/StaticAnalysisEvents.kt | 40 ++++++ .../events/serialization/Serialization.kt | 2 + .../StaticAnalysisEventSerializationTest.kt | 48 +++++++ .../kernel/orchestration/ContextFeedback.kt | 45 ++++++ .../orchestration/SessionOrchestrator.kt | 13 +- .../orchestration/StaticAnalysisRunner.kt | 136 ++++++++++++++++++ .../ExcludeStaticFindingsTest.kt | 74 ++++++++++ .../orchestration/StaticAnalysisRunnerTest.kt | 103 +++++++++++++ 8 files changed, 459 insertions(+), 2 deletions(-) create mode 100644 core/events/src/main/kotlin/com/correx/core/events/events/StaticAnalysisEvents.kt create mode 100644 core/events/src/test/kotlin/com/correx/core/events/serialization/StaticAnalysisEventSerializationTest.kt create mode 100644 core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/StaticAnalysisRunner.kt create mode 100644 core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/ExcludeStaticFindingsTest.kt create mode 100644 core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/StaticAnalysisRunnerTest.kt diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/StaticAnalysisEvents.kt b/core/events/src/main/kotlin/com/correx/core/events/events/StaticAnalysisEvents.kt new file mode 100644 index 00000000..b5c1c5b8 --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/events/StaticAnalysisEvents.kt @@ -0,0 +1,40 @@ +package com.correx.core.events.events + +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +enum class StaticFindingSeverity { ERROR, WARNING, INFO } + +/** + * One deterministic finding from a static-analysis tool (compiler, ktlint, detekt, …). + * [tool] is the tool name; [file]/[line]/[column] locate it; [ruleId] is the tool's rule + * identifier when it emits one (e.g. detekt's `[MagicNumber]`). + */ +@Serializable +data class StaticFinding( + val tool: String, + val file: String, + val line: Int, + val column: Int = 0, + val severity: StaticFindingSeverity, + val message: String, + val ruleId: String? = null, +) + +/** + * Deterministic static-analysis output recorded before review so the reviewer can be given a + * clean, static-issue-free context (BACKLOG §B-§5): the compile/lint/format findings a tool + * already caught are mechanically excluded from the reviewer's context, so the LLM reviewer + * spends its attention on semantic review rather than re-finding what the build already reports. + */ +@Serializable +@SerialName("StaticFindingsRecorded") +data class StaticFindingsRecordedEvent( + val sessionId: SessionId, + val stageId: StageId, + val tool: String, + val findings: List, +) : EventPayload diff --git a/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt b/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt index d9b17031..2c394076 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt @@ -49,6 +49,7 @@ import com.correx.core.events.events.RetryAttemptedEvent import com.correx.core.events.events.WorkspaceStateObservedEvent import com.correx.core.events.events.RiskAssessedEvent import com.correx.core.events.events.SourceFetchedEvent +import com.correx.core.events.events.StaticFindingsRecordedEvent import com.correx.core.events.events.StageCheckpointFailedEvent import com.correx.core.events.events.StageCheckpointPassedEvent import com.correx.core.events.events.StageCompletedEvent @@ -143,6 +144,7 @@ val eventModule = SerializersModule { subclass(SourceFetchedEvent::class) subclass(LowQualityExtractionEvent::class) subclass(EgressHostsGrantedEvent::class) + subclass(StaticFindingsRecordedEvent::class) } } diff --git a/core/events/src/test/kotlin/com/correx/core/events/serialization/StaticAnalysisEventSerializationTest.kt b/core/events/src/test/kotlin/com/correx/core/events/serialization/StaticAnalysisEventSerializationTest.kt new file mode 100644 index 00000000..b6140068 --- /dev/null +++ b/core/events/src/test/kotlin/com/correx/core/events/serialization/StaticAnalysisEventSerializationTest.kt @@ -0,0 +1,48 @@ +package com.correx.core.events.serialization + +import com.correx.core.events.events.EventPayload +import com.correx.core.events.events.StaticFinding +import com.correx.core.events.events.StaticFindingSeverity +import com.correx.core.events.events.StaticFindingsRecordedEvent +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class StaticAnalysisEventSerializationTest { + + @Test + fun `StaticFindingsRecordedEvent round-trips as polymorphic EventPayload`() { + val sample: EventPayload = StaticFindingsRecordedEvent( + sessionId = SessionId("s"), + stageId = StageId("static_check"), + tool = "detekt", + findings = listOf( + StaticFinding( + tool = "detekt", + file = "src/Main.kt", + line = 12, + column = 5, + severity = StaticFindingSeverity.WARNING, + message = "Magic number found", + ruleId = "MagicNumber", + ), + StaticFinding( + tool = "kotlinc", + file = "src/Other.kt", + line = 3, + severity = StaticFindingSeverity.ERROR, + message = "unresolved reference: foo", + ), + ), + ) + val encoded = eventJson.encodeToString(EventPayload.serializer(), sample) + assertTrue( + encoded.contains("\"type\":\"StaticFindingsRecorded\""), + "SerialName must be present: $encoded", + ) + val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded) + assertEquals(sample, decoded) + } +} diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/ContextFeedback.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/ContextFeedback.kt index 5112ecff..5fcab52e 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/ContextFeedback.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/ContextFeedback.kt @@ -10,6 +10,7 @@ import com.correx.core.context.model.ContextLayer import com.correx.core.context.model.EntryRole import com.correx.core.events.events.RefinementIterationEvent import com.correx.core.events.events.RetryAttemptedEvent +import com.correx.core.events.events.StaticFinding import com.correx.core.events.events.StoredEvent import com.correx.core.events.types.ArtifactId import com.correx.core.events.types.ContextEntryId @@ -85,6 +86,50 @@ fun buildRelevantFilesEntry(hits: List): ContextEntry { ) } +/** + * `sourceType`s whose entries merely restate deterministic static-analysis output. Entries tagged + * with any of these are static-derived by construction and dropped wholesale before review. + */ +private val STATIC_DERIVED_SOURCE_TYPES = setOf("staticAnalysis", "staticFindings", "lintFindings") + +/** + * Mechanically excludes context entries that merely restate static-analysis findings from the + * reviewer's context (BACKLOG §B-§5), so the LLM reviewer spends its attention on semantic review + * instead of re-finding compile/lint errors a deterministic tool already caught. + * + * The rule is concrete and deterministic, applied per entry: + * 1. Drop the entry outright if its [ContextEntry.sourceType] is static-analysis-derived + * (see [STATIC_DERIVED_SOURCE_TYPES]). + * 2. Otherwise drop it if its content references a `file:line` location that exactly matches a + * recorded [StaticFinding] (same `file` and `line`) — i.e. the entry is talking about an issue + * the static tool already reported at that exact site. + * + * Pure and identity-preserving: empty [staticFindings] returns [entries] unchanged. + */ +fun excludeStaticFindingsFromReview( + entries: List, + staticFindings: List, +): List { + if (staticFindings.isEmpty()) return entries + val findingSites: Set = staticFindings.map { siteKey(it.file, it.line) }.toSet() + return entries.filterNot { entry -> + entry.sourceType in STATIC_DERIVED_SOURCE_TYPES || referencesAnyFindingSite(entry.content, findingSites) + } +} + +/** Canonical `file:line` key. Trailing path segment match keeps it robust to path-prefix drift. */ +private fun siteKey(file: String, line: Int): String = "${file.substringAfterLast('/')}:$line" + +/** True if [content] mentions a `file:line` location present in [findingSites]. */ +private fun referencesAnyFindingSite(content: String, findingSites: Set): Boolean = + FILE_LINE_REFERENCE.findAll(content).any { match -> + val (file, line) = match.destructured + "${file.substringAfterLast('/')}:$line" in findingSites + } + +// A `path/to/File.ext:line` reference anywhere in free text (path may include directories). +private val FILE_LINE_REFERENCE = Regex("""([\w./\-]+\.\w+):(\d+)""") + fun buildProjectProfileEntry(profile: BoundProjectProfile): ContextEntry { val content = buildString { append("## Project profile\n") diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt index 3d3d946b..fdde54ec 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt @@ -28,6 +28,7 @@ import com.correx.core.events.events.RefinementIterationEvent import com.correx.core.events.events.ApprovalRequestedEvent import com.correx.core.events.events.ArtifactContentStoredEvent import com.correx.core.events.events.ArtifactCreatedEvent +import com.correx.core.events.events.StaticFindingsRecordedEvent import com.correx.core.events.events.BriefEchoMismatchEvent import com.correx.core.events.events.BriefGroundingCheckedEvent import com.correx.core.events.events.ContextTruncatedEvent @@ -402,10 +403,18 @@ abstract class SessionOrchestrator( val vocabularyEntries = artifactKindRegistry ?.takeIf { stageConfig.metadata["injectArtifactKinds"] == "true" } ?.let { listOf(buildArtifactKindVocabularyEntry(it.list())) } ?: emptyList() - var accumulatedEntries = + // Mechanically strip entries that merely restate static-analysis findings already recorded + // this session (BACKLOG §B-§5), so a downstream reviewer stage doesn't re-find compile/lint + // issues a deterministic tool caught. No-op until a static_check stage has emitted findings. + val recordedStaticFindings = sessionEvents + .mapNotNull { it.payload as? StaticFindingsRecordedEvent } + .flatMap { it.findings } + var accumulatedEntries = excludeStaticFindingsFromReview( systemPrompt + profileEntries + projectProfileEntries + journalEntries + repoMapEntries + needsEntries + schemaEntries + vocabularyEntries + promptEntries + steeringEntries + - clarificationEntries + retryFeedbackEntries + clarificationEntries + retryFeedbackEntries, + recordedStaticFindings, + ) val contextPack = contextPackBuilder.build( id = ContextPackId(UUID.randomUUID().toString()), sessionId = sessionId, diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/StaticAnalysisRunner.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/StaticAnalysisRunner.kt new file mode 100644 index 00000000..011279ae --- /dev/null +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/StaticAnalysisRunner.kt @@ -0,0 +1,136 @@ +package com.correx.core.kernel.orchestration + +import com.correx.core.events.events.StaticFinding +import com.correx.core.events.events.StaticFindingSeverity + +/** Output of one external command run. */ +data class CommandOutput(val exitCode: Int, val stdout: String, val stderr: String) + +/** + * Minimal injectable seam for running an external static-analysis command. Kept as an interface + * (not a direct process spawn) so [StaticAnalysisRunner] stays pure and unit-testable: a fake + * runner returns canned output in tests; a real one shells out via the harness's sandbox. The + * established pattern in this package (see [RepoKnowledgeRetriever]) is a one-method `suspend` + * port. + */ +interface CommandRunner { + suspend fun run(argv: List): CommandOutput +} + +/** + * Runs a deterministic static-analysis tool and parses its console output into [StaticFinding]s + * (BACKLOG §B-§5). Pure of side effects beyond calling [runner]: no real process spawning lives + * here, so the parsing logic and the runner wiring are independently testable. + * + * Both stdout and stderr are parsed — compilers write diagnostics to stderr, ktlint/detekt to + * stdout — and parsing is lenient: lines that don't match any known format are ignored. + * + * TODO(wiring): a real `static_check` stage in role_pipeline.toml (between `implementer` and + * `reviewer`) would invoke this runner and emit the findings as a first-class event: + * + * val findings = StaticAnalysisRunner(commandRunner).analyze("detekt", argv) + * eventDispatcher.emit(StaticFindingsRecordedEvent(sessionId, stageId, "detekt", findings), sessionId) + * + * Once that event is on the journal, [excludeStaticFindingsFromReview] (already live-wired in + * SessionOrchestrator's context assembly) strips the matching entries from the reviewer's context. + * That stage is intentionally NOT added here: workflow stages are LLM-driven and emit typed + * artifacts, so a deterministic event-emitting stage needs a new stage-type / tool-execution seam + * that doesn't yet exist — adding it now would be broad plumbing. The runner + event + filter are + * complete; only the stage that calls them is deferred. + */ +class StaticAnalysisRunner(private val runner: CommandRunner) { + + suspend fun analyze(tool: String, argv: List): List { + val output = runner.run(argv) + val combined = sequenceOf(output.stdout, output.stderr) + .filter { it.isNotEmpty() } + .flatMap { it.lineSequence() } + return combined.mapNotNull { parseLine(tool, it) }.toList() + } + + companion object { + // Order matters: detekt's `[RuleId]` suffix is a superset of the generic compiler shape, + // so it is attempted first; the generic `path:line:col: severity: message` next; the + // bare `path:line: message` last. + private val DETEKT = Regex("""^\s*(\S.*?):(\d+):(\d+):\s*(.+?)\s*\[([A-Za-z0-9_.]+)]\s*$""") + private val COMPILER_COL = + Regex("""^\s*(\S.*?):(\d+):(\d+):\s*(error|warning|info)\s*:\s*(.+?)\s*$""", RegexOption.IGNORE_CASE) + private val COMPILER_SEV = + Regex("""^\s*(\S.*?):(\d+):\s*(error|warning|info)\s*:\s*(.+?)\s*$""", RegexOption.IGNORE_CASE) + private val PATH_LINE = Regex("""^\s*(\S.*?):(\d+):\s*(.+?)\s*$""") + + /** + * Parses a single console line into a [StaticFinding], or null if it matches no known + * format. Public + companion-scoped so each format is independently unit-testable. + */ + fun parseLine(tool: String, line: String): StaticFinding? { + detektFinding(tool, line)?.let { return it } + compilerWithColumn(tool, line)?.let { return it } + compilerWithSeverity(tool, line)?.let { return it } + return pathLine(tool, line) + } + + /** detekt console: `path:line:col: Message [RuleId]` — no explicit severity → WARNING. */ + private fun detektFinding(tool: String, line: String): StaticFinding? { + val m = DETEKT.matchEntire(line) ?: return null + val (file, ln, col, message, ruleId) = m.destructured + return StaticFinding( + tool = tool, + file = file, + line = ln.toInt(), + column = col.toInt(), + severity = StaticFindingSeverity.WARNING, + message = message, + ruleId = ruleId, + ) + } + + /** compiler / ktlint: `path:line:col: severity: message`. */ + private fun compilerWithColumn(tool: String, line: String): StaticFinding? { + val m = COMPILER_COL.matchEntire(line) ?: return null + val (file, ln, col, sev, message) = m.destructured + return StaticFinding( + tool = tool, + file = file, + line = ln.toInt(), + column = col.toInt(), + severity = severityOf(sev), + message = message, + ) + } + + /** compiler: `path:line: severity: message` (no column). */ + private fun compilerWithSeverity(tool: String, line: String): StaticFinding? { + val m = COMPILER_SEV.matchEntire(line) ?: return null + val (file, ln, sev, message) = m.destructured + return StaticFinding( + tool = tool, + file = file, + line = ln.toInt(), + severity = severityOf(sev), + message = message, + ) + } + + /** bare `path:line: message` — no severity token → WARNING. */ + private fun pathLine(tool: String, line: String): StaticFinding? { + val m = PATH_LINE.matchEntire(line) ?: return null + val (file, ln, message) = m.destructured + return StaticFinding( + tool = tool, + file = file, + line = ln.toInt(), + severity = StaticFindingSeverity.WARNING, + message = message, + ) + } + + /** Maps a severity token to the enum; anything unrecognised defaults to WARNING. */ + fun severityOf(token: String): StaticFindingSeverity = when (token.trim().lowercase()) { + "error" -> StaticFindingSeverity.ERROR + "warning" -> StaticFindingSeverity.WARNING + "info" -> StaticFindingSeverity.INFO + else -> StaticFindingSeverity.WARNING + } + } +} diff --git a/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/ExcludeStaticFindingsTest.kt b/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/ExcludeStaticFindingsTest.kt new file mode 100644 index 00000000..8ad5d257 --- /dev/null +++ b/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/ExcludeStaticFindingsTest.kt @@ -0,0 +1,74 @@ +package com.correx.core.kernel.orchestration + +import com.correx.core.context.model.ContextEntry +import com.correx.core.context.model.ContextLayer +import com.correx.core.context.model.EntryRole +import com.correx.core.events.events.StaticFinding +import com.correx.core.events.events.StaticFindingSeverity +import com.correx.core.events.types.ContextEntryId +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class ExcludeStaticFindingsTest { + + private fun entry(sourceType: String, content: String, id: String = sourceType): ContextEntry = + ContextEntry( + id = ContextEntryId(id), + layer = ContextLayer.L1, + content = content, + sourceType = sourceType, + sourceId = id, + tokenEstimate = content.length / 4, + role = EntryRole.SYSTEM, + ) + + private val finding = StaticFinding( + tool = "detekt", + file = "src/main/Foo.kt", + line = 12, + column = 5, + severity = StaticFindingSeverity.WARNING, + message = "Magic number found", + ruleId = "MagicNumber", + ) + + @Test + fun `drops a static-analysis-derived entry by sourceType`() { + val entries = listOf( + entry("staticFindings", "Foo.kt:12 Magic number found"), + entry("agentPrompt", "Review the patch against the requirements."), + ) + val result = excludeStaticFindingsFromReview(entries, listOf(finding)) + assertEquals(listOf("agentPrompt"), result.map { it.sourceType }) + } + + @Test + fun `drops an entry referencing a recorded finding site`() { + val entries = listOf( + entry("toolResult", "The build reported src/main/Foo.kt:12: magic number — please address."), + entry("agentPrompt", "Does the change satisfy each requirement?"), + ) + val result = excludeStaticFindingsFromReview(entries, listOf(finding)) + assertEquals(listOf("agentPrompt"), result.map { it.sourceType }) + } + + @Test + fun `keeps a semantic entry that references an unrelated site`() { + val semantic = entry("toolResult", "Consider extracting Bar.kt:99 into a helper for clarity.") + val result = excludeStaticFindingsFromReview(listOf(semantic), listOf(finding)) + assertEquals(1, result.size) + assertTrue(result.contains(semantic)) + } + + @Test + fun `empty findings is identity`() { + val entries = listOf( + entry("staticFindings", "Foo.kt:12 Magic number found"), + entry("agentPrompt", "Review the patch."), + ) + val result = excludeStaticFindingsFromReview(entries, emptyList()) + assertSame(entries, result) + } +} diff --git a/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/StaticAnalysisRunnerTest.kt b/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/StaticAnalysisRunnerTest.kt new file mode 100644 index 00000000..88b6e99b --- /dev/null +++ b/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/StaticAnalysisRunnerTest.kt @@ -0,0 +1,103 @@ +package com.correx.core.kernel.orchestration + +import com.correx.core.events.events.StaticFinding +import com.correx.core.events.events.StaticFindingSeverity +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +class StaticAnalysisRunnerTest { + + @Test + fun `parses compiler file line col error message`() { + val finding = StaticAnalysisRunner.parseLine("kotlinc", "src/Main.kt:12:5: error: unresolved reference: foo") + assertEquals( + StaticFinding( + tool = "kotlinc", + file = "src/Main.kt", + line = 12, + column = 5, + severity = StaticFindingSeverity.ERROR, + message = "unresolved reference: foo", + ), + finding, + ) + } + + @Test + fun `parses ktlint style warning with column`() { + val finding = StaticAnalysisRunner.parseLine("ktlint", "src/Util.kt:3:1: warning: Unexpected blank line(s)") + assertEquals("src/Util.kt", finding?.file) + assertEquals(3, finding?.line) + assertEquals(1, finding?.column) + assertEquals(StaticFindingSeverity.WARNING, finding?.severity) + assertEquals("Unexpected blank line(s)", finding?.message) + assertNull(finding?.ruleId) + } + + @Test + fun `parses detekt console line with rule id`() { + val finding = StaticAnalysisRunner.parseLine("detekt", "src/Foo.kt:42:9: Magic number found [MagicNumber]") + assertEquals( + StaticFinding( + tool = "detekt", + file = "src/Foo.kt", + line = 42, + column = 9, + severity = StaticFindingSeverity.WARNING, + message = "Magic number found", + ruleId = "MagicNumber", + ), + finding, + ) + } + + @Test + fun `parses bare file line message without column or severity`() { + val finding = StaticAnalysisRunner.parseLine("tool", "src/Bar.kt:7: something is off") + assertEquals("src/Bar.kt", finding?.file) + assertEquals(7, finding?.line) + assertEquals(0, finding?.column) + assertEquals(StaticFindingSeverity.WARNING, finding?.severity) + assertEquals("something is off", finding?.message) + } + + @Test + fun `unparseable line is ignored`() { + assertNull(StaticAnalysisRunner.parseLine("tool", "BUILD SUCCESSFUL in 3s")) + assertNull(StaticAnalysisRunner.parseLine("tool", "")) + assertNull(StaticAnalysisRunner.parseLine("tool", "just some prose with no location")) + } + + @Test + fun `severity mapping covers error warning info and default`() { + assertEquals(StaticFindingSeverity.ERROR, StaticAnalysisRunner.severityOf("error")) + assertEquals(StaticFindingSeverity.WARNING, StaticAnalysisRunner.severityOf("WARNING")) + assertEquals(StaticFindingSeverity.INFO, StaticAnalysisRunner.severityOf("Info")) + assertEquals(StaticFindingSeverity.WARNING, StaticAnalysisRunner.severityOf("note")) + } + + @Test + fun `analyze parses canned multi-line output from both streams`() = runBlocking { + val runner = StaticAnalysisRunner( + object : CommandRunner { + override suspend fun run(argv: List): CommandOutput = CommandOutput( + exitCode = 1, + stdout = listOf( + "src/A.kt:10:4: warning: deprecated api use", + "src/B.kt:2:1: Magic number found [MagicNumber]", + "BUILD FAILED", + ).joinToString("\n"), + stderr = "src/C.kt:5:9: error: type mismatch", + ) + }, + ) + val findings = runner.analyze("static", listOf("./gradlew", "check")) + assertEquals(3, findings.size) + assertEquals(StaticFindingSeverity.WARNING, findings[0].severity) + assertEquals("MagicNumber", findings[1].ruleId) + assertEquals(StaticFindingSeverity.ERROR, findings[2].severity) + assertEquals("src/C.kt", findings[2].file) + } +} From eff8f8dbdfddc3a6219e84002aee8a432fd15f82 Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 20 Jun 2026 16:02:09 +0000 Subject: [PATCH 014/107] fix(narration): drop stale pause narrations once approval resolves (G) Pause narrations are tagged with a pauseKey; a per-session pending-pause set gates the lane worker so a resolved/resumed pause is skipped instead of blending with the current status. ApprovalDecisionResolved drops the specific request's pause; OrchestrationResumed clears all session pauses. Replay-safe (no recorded events change). Co-Authored-By: Claude Opus 4.8 --- .../server/narration/NarrationSubscriber.kt | 74 +++++++++- .../narration/NarrationSubscriberTest.kt | 136 ++++++++++++++++++ 2 files changed, 204 insertions(+), 6 deletions(-) diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/narration/NarrationSubscriber.kt b/apps/server/src/main/kotlin/com/correx/apps/server/narration/NarrationSubscriber.kt index e762c82e..09658eee 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/narration/NarrationSubscriber.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/narration/NarrationSubscriber.kt @@ -1,8 +1,10 @@ package com.correx.apps.server.narration +import com.correx.core.events.events.ApprovalDecisionResolvedEvent import com.correx.core.events.events.ApprovalRequestedEvent import com.correx.core.events.events.ExecutionPlanRejectedEvent import com.correx.core.events.events.OrchestrationPausedEvent +import com.correx.core.events.events.OrchestrationResumedEvent import com.correx.core.events.events.StageCompletedEvent import com.correx.core.events.events.StageFailedEvent import com.correx.core.events.events.StoredEvent @@ -20,6 +22,7 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import org.slf4j.LoggerFactory +import java.util.Collections import java.util.concurrent.ConcurrentHashMap class NarrationSubscriber( @@ -28,7 +31,27 @@ class NarrationSubscriber( private val scope: CoroutineScope, private val maxPerRun: Int = DEFAULT_MAX_PER_RUN, ) { - private data class SessionLane(val channel: Channel, var used: Int) + /** + * A queued narration plus the pause-trigger bookkeeping needed to drop it if the + * pause it described resolves before the lane worker delivers it. + * + * [pauseKey] is non-null only for pause-trigger narrations. It is the resolving signal's + * scope: the originating approval request id when known (per-request supersession), or a + * session-wide sentinel for non-approval pauses (per-session supersession). + */ + private data class QueuedNarration(val trigger: NarrationTrigger, val pauseKey: String?) + + /** + * Per-session lane. [pendingPauses] holds the pause keys still queued (not yet delivered + * by the worker). When an approval resolves or the orchestration resumes, the matching + * keys are removed so the worker skips the now-stale pause narration instead of blending + * it with the current status. + */ + private data class SessionLane( + val channel: Channel, + var used: Int, + val pendingPauses: MutableSet = Collections.newSetFromMap(ConcurrentHashMap()), + ) private val lanes = ConcurrentHashMap() @@ -50,6 +73,7 @@ class NarrationSubscriber( instruction = approvalInstruction(p), stageId = p.stageId?.value, ), + pauseKey = pauseKeyForRequest(p.requestId.value), ) is StageCompletedEvent -> enqueue( sid, @@ -100,9 +124,16 @@ class NarrationSubscriber( instruction = "Orchestration paused in stage ${p.stageId.value}: ${p.reason}. Inform the user.", stageId = p.stageId.value, ), + pauseKey = SESSION_PAUSE_KEY, ) } } + // An approval resolving makes its queued pause narration stale: drop it (scoped to + // that request) so a resolved pause never narrates as if still pending. + is ApprovalDecisionResolvedEvent -> dropPendingPause(sid, pauseKeyForRequest(p.requestId.value)) + // Resuming supersedes every pause still queued for the session (approval-pending or + // not): the lane is live again, so any pending "paused" narration is stale. + is OrchestrationResumedEvent -> dropAllPendingPauses(sid) else -> Unit } } @@ -122,20 +153,46 @@ class NarrationSubscriber( } } - private fun enqueue(sessionId: SessionId, trigger: NarrationTrigger) { + private fun enqueue(sessionId: SessionId, trigger: NarrationTrigger, pauseKey: String? = null) { val lane = lanes.computeIfAbsent(sessionId.value) { startLane(sessionId) } if (lane.used >= maxPerRun) { log.debug("narration budget reached for session {}; skipping {}", sessionId.value, trigger.kind) return } lane.used += 1 - lane.channel.trySend(trigger) + if (pauseKey != null) { + lane.pendingPauses.add(pauseKey) + } + lane.channel.trySend(QueuedNarration(trigger, pauseKey)) + } + + /** Marks the pause for [pauseKey] resolved so the lane worker skips it if still queued. */ + private fun dropPendingPause(sessionId: SessionId, pauseKey: String) { + lanes[sessionId.value]?.pendingPauses?.remove(pauseKey) + } + + /** Marks every queued pause for the session resolved (used on resume). */ + private fun dropAllPendingPauses(sessionId: SessionId) { + lanes[sessionId.value]?.pendingPauses?.clear() } private fun startLane(sessionId: SessionId): SessionLane { - val channel = Channel(capacity = Channel.UNLIMITED) + val channel = Channel(capacity = Channel.UNLIMITED) + val lane = SessionLane(channel, used = 0) scope.launch { - for (trigger in channel) { + for (queued in channel) { + // A pause whose key was dropped (approval resolved / resumed) is stale: skip it + // rather than narrate a paused state that no longer holds. removeIf-style check: + // claim the key so a re-enqueued pause with the same id is not also dropped. + if (queued.pauseKey != null && !lane.pendingPauses.remove(queued.pauseKey)) { + log.debug( + "skipping stale pause narration for session {} (key {})", + sessionId.value, + queued.pauseKey, + ) + continue + } + val trigger = queued.trigger runCatching { routerFacade.narrate(sessionId, trigger) } .onFailure { e -> if (e is CancellationException) throw e @@ -148,12 +205,17 @@ class NarrationSubscriber( } } } - return SessionLane(channel, used = 0) + return lane } companion object { private const val DEFAULT_MAX_PER_RUN = 100 private const val MAX_PREVIEW_CHARS = 800 + + /** Supersession scope for pauses with no approval request (e.g. USER_REQUESTED). */ + private const val SESSION_PAUSE_KEY = "__session_pause__" private val log = LoggerFactory.getLogger(NarrationSubscriber::class.java) + + private fun pauseKeyForRequest(requestId: String): String = "req:$requestId" } } diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/narration/NarrationSubscriberTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/narration/NarrationSubscriberTest.kt index 3bdf5515..c5f198ab 100644 --- a/apps/server/src/test/kotlin/com/correx/apps/server/narration/NarrationSubscriberTest.kt +++ b/apps/server/src/test/kotlin/com/correx/apps/server/narration/NarrationSubscriberTest.kt @@ -1,18 +1,23 @@ package com.correx.apps.server.narration +import com.correx.core.approvals.ApprovalOutcome +import com.correx.core.approvals.ApprovalStatus 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.EventMetadata import com.correx.core.events.events.EventPayload import com.correx.core.events.events.ExecutionPlanRejectedEvent import com.correx.core.events.events.NewEvent import com.correx.core.events.events.OrchestrationPausedEvent +import com.correx.core.events.events.OrchestrationResumedEvent import com.correx.core.events.events.StageCompletedEvent import com.correx.core.events.events.StageFailedEvent import com.correx.core.events.events.StoredEvent import com.correx.core.events.events.WorkflowCompletedEvent import com.correx.core.events.events.WorkflowStartedEvent import com.correx.core.events.stores.EventStore +import com.correx.core.events.types.ApprovalDecisionId import com.correx.core.events.types.ApprovalRequestId import com.correx.core.events.types.EventId import com.correx.core.events.types.SessionId @@ -24,10 +29,12 @@ import com.correx.core.router.ChatMode import com.correx.core.router.RouterFacade import com.correx.core.router.model.NarrationTrigger import com.correx.core.router.model.RouterResponse +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.runBlocking @@ -231,4 +238,133 @@ class NarrationSubscriberTest { "instruction must include the source", ) } + + /** + * Router facade that blocks the lane worker on the first narration until [release] completes, + * letting the test enqueue further events (and resolve the pause) while the pause narration is + * still pending in the channel. + */ + private class GatingRouterFacade : RouterFacade { + val calls = CopyOnWriteArrayList() + val release = CompletableDeferred() + // Signals that the worker has pulled the first (warmup) trigger and is now blocked, + // so any narrations enqueued afterwards are guaranteed to still be queued. + val blocked = CompletableDeferred() + private var first = true + + override suspend fun onUserInput(sessionId: SessionId, input: String, mode: ChatMode): RouterResponse = + error("unused in narration tests") + + override suspend fun narrate(sessionId: SessionId, trigger: NarrationTrigger) { + if (first) { + first = false + blocked.complete(Unit) + release.await() + } + calls.add(trigger) + } + } + + private fun approvalRequested(requestId: String): ApprovalRequestedEvent = ApprovalRequestedEvent( + requestId = ApprovalRequestId(requestId), + tier = Tier.T2, + validationReportId = ValidationReportId("vr-1"), + riskSummaryId = null, + sessionId = sessionId, + stageId = StageId("write_script"), + projectId = null, + toolName = "file_write", + preview = "--- a/x.sh\n+++ b/x.sh", + ) + + private fun approvalResolved(requestId: String): ApprovalDecisionResolvedEvent = ApprovalDecisionResolvedEvent( + decisionId = ApprovalDecisionId("dec-1"), + requestId = ApprovalRequestId(requestId), + outcome = ApprovalOutcome.APPROVED, + status = ApprovalStatus.COMPLETED, + tier = Tier.T2, + resolutionTimestamp = timestamp, + reason = null, + ) + + @Test + fun `resolved approval drops the still-queued pause narration`(): Unit = runBlocking { + val facade = GatingRouterFacade() + NarrationSubscriber(fakeStore, facade, scope).start() + + while (liveFlow.subscriptionCount.value == 0) yield() + + liveFlow.emit(storedEvent(WorkflowStartedEvent(sessionId, "wf", StageId("s0")), seq = 1L)) + // Warmup narration the worker will block on, keeping later narrations queued. + liveFlow.emit(storedEvent(StageCompletedEvent(sessionId, StageId("a"), TransitionId("t1")), seq = 2L)) + // Wait until the worker is parked on the warmup before enqueuing the pause, so the pause + // is provably still queued (not yet delivered) when its approval resolves. + withTimeout(2_000L) { facade.blocked.await() } + // Pause is enqueued, then its approval resolves before the worker can drain it. + liveFlow.emit(storedEvent(approvalRequested("req-1"), seq = 3L)) + liveFlow.emit(storedEvent(approvalResolved("req-1"), seq = 4L)) + // A current, unrelated narration arrives after resolution and must still surface. + liveFlow.emit(storedEvent(StageCompletedEvent(sessionId, StageId("b"), TransitionId("t2")), seq = 5L)) + // Let the (single, FIFO) collector drain its buffer so the resolve is handled pre-release. + delay(100L) + + // Release the worker; it drains the warmup, skips the stale pause, narrates the second stage. + facade.release.complete(Unit) + + withTimeout(2_000L) { while (facade.calls.size < 2) yield() } + delay(100L) // allow a stray (stale) pause narration to arrive if the drop failed + + assertEquals(listOf("stage_completed", "stage_completed"), facade.calls.map { it.kind }) + assertTrue(facade.calls.none { it.kind == "paused" }, "stale pause narration must be dropped") + } + + @Test + fun `unrelated approval resolution leaves the pending pause intact`(): Unit = runBlocking { + val facade = GatingRouterFacade() + NarrationSubscriber(fakeStore, facade, scope).start() + + while (liveFlow.subscriptionCount.value == 0) yield() + + liveFlow.emit(storedEvent(WorkflowStartedEvent(sessionId, "wf", StageId("s0")), seq = 1L)) + liveFlow.emit(storedEvent(StageCompletedEvent(sessionId, StageId("a"), TransitionId("t1")), seq = 2L)) + withTimeout(2_000L) { facade.blocked.await() } + // Pause for req-1, but a *different* request (req-2) resolves: req-1's pause must survive. + liveFlow.emit(storedEvent(approvalRequested("req-1"), seq = 3L)) + liveFlow.emit(storedEvent(approvalResolved("req-2"), seq = 4L)) + delay(100L) + + facade.release.complete(Unit) + + withTimeout(2_000L) { while (facade.calls.size < 2) yield() } + + assertEquals(listOf("stage_completed", "paused"), facade.calls.map { it.kind }) + } + + @Test + fun `resume drops every queued pause narration for the session`(): Unit = runBlocking { + val facade = GatingRouterFacade() + NarrationSubscriber(fakeStore, facade, scope).start() + + while (liveFlow.subscriptionCount.value == 0) yield() + + liveFlow.emit(storedEvent(WorkflowStartedEvent(sessionId, "wf", StageId("s0")), seq = 1L)) + liveFlow.emit(storedEvent(StageCompletedEvent(sessionId, StageId("a"), TransitionId("t1")), seq = 2L)) + withTimeout(2_000L) { facade.blocked.await() } + // A non-approval pause (session-scoped) plus an approval pause, both superseded by resume. + liveFlow.emit( + storedEvent(OrchestrationPausedEvent(sessionId, StageId("a"), "USER_REQUESTED"), seq = 3L), + ) + liveFlow.emit(storedEvent(approvalRequested("req-1"), seq = 4L)) + liveFlow.emit(storedEvent(OrchestrationResumedEvent(sessionId, StageId("a")), seq = 5L)) + liveFlow.emit(storedEvent(StageCompletedEvent(sessionId, StageId("b"), TransitionId("t2")), seq = 6L)) + delay(100L) + + facade.release.complete(Unit) + + withTimeout(2_000L) { while (facade.calls.size < 2) yield() } + delay(100L) + + assertEquals(listOf("stage_completed", "stage_completed"), facade.calls.map { it.kind }) + assertTrue(facade.calls.none { it.kind == "paused" }, "resume must drop all queued pauses") + } } From 1e6699a360090861c8ad4c9381cda1c8b0746d66 Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 20 Jun 2026 16:07:36 +0000 Subject: [PATCH 015/107] fix(tui-go): go.mod directive 1.26 -> 1.24.2 (1.26 toolchain unreleased) `go 1.26` is not a fetchable/released toolchain, so released Go cannot build the module ("toolchain not available"). 1.24.2 is the minimum the deps require (charmbracelet/x/ansi needs >= 1.24.2) and is a real toolchain; the directive is a floor, so newer Go still works. Bump if a specific newer release is intended. Co-Authored-By: Claude Opus 4.8 --- apps/tui-go/go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/tui-go/go.mod b/apps/tui-go/go.mod index d1dee92b..b3d89eb2 100644 --- a/apps/tui-go/go.mod +++ b/apps/tui-go/go.mod @@ -1,6 +1,6 @@ module github.com/correx/tui-go -go 1.26 +go 1.24.2 require ( github.com/charmbracelet/bubbletea v1.3.10 From da72ee5b80e6532e7361a1ac688dfc2b6b8ff808 Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 20 Jun 2026 16:12:16 +0000 Subject: [PATCH 016/107] feat(tui-go): render markdown in chat turns (E) renderMarkdown wraps charmbracelet/glamour (dark style, per-width memoized) and is hooked into the router chat-turn render path; falls back to plain content on any glamour error so the TUI never crashes or loses text. Other roles/UI untouched. Co-Authored-By: Claude Opus 4.8 --- apps/tui-go/go.mod | 16 +++- apps/tui-go/go.sum | 45 ++++++++++- apps/tui-go/internal/app/markdown.go | 76 ++++++++++++++++++ apps/tui-go/internal/app/markdown_test.go | 94 +++++++++++++++++++++++ apps/tui-go/internal/app/view.go | 10 ++- 5 files changed, 233 insertions(+), 8 deletions(-) create mode 100644 apps/tui-go/internal/app/markdown.go create mode 100644 apps/tui-go/internal/app/markdown_test.go diff --git a/apps/tui-go/go.mod b/apps/tui-go/go.mod index b3d89eb2..84392ea9 100644 --- a/apps/tui-go/go.mod +++ b/apps/tui-go/go.mod @@ -4,29 +4,41 @@ go 1.24.2 require ( github.com/charmbracelet/bubbletea v1.3.10 - github.com/charmbracelet/lipgloss v1.1.0 + github.com/charmbracelet/glamour v1.0.0 + github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 github.com/charmbracelet/x/ansi v0.11.6 github.com/gorilla/websocket v1.5.3 github.com/muesli/termenv v0.16.0 ) require ( + github.com/alecthomas/chroma/v2 v2.20.0 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/aymerick/douceur v0.2.0 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.9.0 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/gorilla/css v1.0.1 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/reflow v0.3.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yuin/goldmark v1.7.13 // indirect + github.com/yuin/goldmark-emoji v1.0.6 // indirect + golang.org/x/net v0.38.0 // indirect golang.org/x/sys v0.38.0 // indirect - golang.org/x/text v0.3.8 // indirect + golang.org/x/term v0.36.0 // indirect + golang.org/x/text v0.30.0 // indirect ) diff --git a/apps/tui-go/go.sum b/apps/tui-go/go.sum index 19aef85f..321fbe29 100644 --- a/apps/tui-go/go.sum +++ b/apps/tui-go/go.sum @@ -1,15 +1,31 @@ +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw= +github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA= +github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg= +github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= +github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= -github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= -github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08= +github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30= +github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= @@ -18,33 +34,54 @@ github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfa github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= +github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= +github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= +golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= -golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= +golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= diff --git a/apps/tui-go/internal/app/markdown.go b/apps/tui-go/internal/app/markdown.go new file mode 100644 index 00000000..503b73ce --- /dev/null +++ b/apps/tui-go/internal/app/markdown.go @@ -0,0 +1,76 @@ +package app + +import ( + "strings" + "sync" + + "github.com/charmbracelet/glamour" +) + +// defaultMarkdownWidth is the wrap width used when the caller doesn't yet know +// the terminal width (e.g. a zero/negative value). 80 columns is a safe terminal +// default that never blows up glamour's word-wrap. +const defaultMarkdownWidth = 80 + +// markdownRenderers memoizes one glamour renderer per wrap width. A glamour +// renderer bakes its word-wrap width in at construction, so we key the cache by +// width and build lazily. Constructing a renderer is comparatively expensive +// (it compiles a style + goldmark parser), hence the cache; the TUI renders the +// same handful of widths over its lifetime. +var ( + markdownMu sync.Mutex + markdownRenderers = map[int]*glamour.TermRenderer{} +) + +// rendererForWidth returns a cached dark-styled glamour renderer wrapped to w, +// or nil if glamour could not build one (caller falls back to plain text). +func rendererForWidth(w int) *glamour.TermRenderer { + markdownMu.Lock() + defer markdownMu.Unlock() + if r, ok := markdownRenderers[w]; ok { + return r + } + // Pin the "dark" standard style so output is deterministic and independent + // of the ambient terminal's background detection (important for tests and + // for headless/over-the-wire sessions). + r, err := glamour.NewTermRenderer( + glamour.WithStandardStyle("dark"), + glamour.WithWordWrap(w), + ) + if err != nil { + // Cache the failure as nil so we don't retry-and-fail every frame. + markdownRenderers[w] = nil + return nil + } + markdownRenderers[w] = r + return r +} + +// renderMarkdown renders content as terminal markdown (bold, italics, lists, +// headings, code blocks/inline code) word-wrapped to width, styled for a dark +// terminal. It is robust by construction: on any glamour error — or if the +// renderer can't be built — it returns the original plain content rather than +// crashing the TUI. Trailing whitespace glamour appends is trimmed so it doesn't +// disrupt the surrounding layout. +func renderMarkdown(content string, width int) string { + if width < 1 { + width = defaultMarkdownWidth + } + r := rendererForWidth(width) + if r == nil { + return content + } + out, err := r.Render(content) + if err != nil { + return content + } + // glamour pads block output with leading/trailing blank lines; strip them so + // the rendered turn slots into the feed without extra gaps. + out = strings.Trim(out, "\n") + if strings.TrimSpace(out) == "" { + // Nothing survived rendering (e.g. content was only whitespace) — prefer + // the original so callers never lose the underlying text. + return content + } + return out +} diff --git a/apps/tui-go/internal/app/markdown_test.go b/apps/tui-go/internal/app/markdown_test.go new file mode 100644 index 00000000..ef72270b --- /dev/null +++ b/apps/tui-go/internal/app/markdown_test.go @@ -0,0 +1,94 @@ +package app + +import ( + "strings" + "testing" +) + +// stripANSI removes CSI escape sequences so we can assert on the visible text +// glamour produced without depending on exact styling codes. +func stripANSI(s string) string { + var b strings.Builder + for i := 0; i < len(s); { + if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '[' { + j := i + 2 + for j < len(s) && (s[j] < 0x40 || s[j] > 0x7e) { + j++ + } + if j < len(s) { + j++ // consume the final byte + } + i = j + continue + } + b.WriteByte(s[i]) + i++ + } + return b.String() +} + +// Rich markdown should be transformed (output differs from raw input) while the +// literal words survive the round-trip. +func TestRenderMarkdownTransformsAndPreservesText(t *testing.T) { + in := "# Heading\n\nSome **bold** and *italic* text.\n\n- first item\n- second item\n\n`inline code` and:\n\n```go\nfmt.Println(\"hi\")\n```\n" + out := renderMarkdown(in, 80) + + if out == in { + t.Fatalf("markdown input should be rendered, got identical output") + } + + plain := stripANSI(out) + for _, want := range []string{"Heading", "bold", "italic", "first item", "second item", "inline code", "fmt.Println"} { + if !strings.Contains(plain, want) { + t.Errorf("rendered output missing %q\n--- visible ---\n%s", want, plain) + } + } +} + +// A plain, short string must round-trip to contain its text and not panic, +// regardless of how much (or how little) styling glamour adds. +func TestRenderMarkdownPlainStringRoundTrips(t *testing.T) { + in := "just a short plain sentence" + out := renderMarkdown(in, 80) + if !strings.Contains(stripANSI(out), in) { + t.Fatalf("plain string did not survive rendering: %q", stripANSI(out)) + } +} + +// Pathological / empty inputs must never panic and must return something +// containing the input text (trivially true for empty input). +func TestRenderMarkdownPathologicalDoesNotPanic(t *testing.T) { + cases := []string{ + "", + " ", + "\n\n\n", + "```unterminated code fence\nstill going", + strings.Repeat("a", 10_000), + "[](()]][[**__", + } + for _, in := range cases { + func() { + defer func() { + if r := recover(); r != nil { + t.Fatalf("renderMarkdown panicked on %q: %v", in, r) + } + }() + out := renderMarkdown(in, 40) + // Empty/whitespace-only inputs fall back to the original content. + if strings.TrimSpace(in) == "" { + if out != in { + t.Errorf("whitespace input %q should fall back to itself, got %q", in, out) + } + } + }() + } +} + +// A non-positive width must not crash and should fall back to the default +// wrap width rather than feeding glamour an invalid value. +func TestRenderMarkdownZeroWidthFallsBack(t *testing.T) { + out := renderMarkdown("**hello** world", 0) + if !strings.Contains(stripANSI(out), "hello") { + t.Fatalf("zero-width render dropped text: %q", stripANSI(out)) + } +} diff --git a/apps/tui-go/internal/app/view.go b/apps/tui-go/internal/app/view.go index de013c8d..f5898920 100644 --- a/apps/tui-go/internal/app/view.go +++ b/apps/tui-go/internal/app/view.go @@ -488,8 +488,14 @@ func (m Model) routerRows(w, h int) []string { tag := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render("›") rows = append(rows, tag+" "+t.span(e.Content, t.P.FgStrong)) case "router": - for _, ln := range wrap(e.Content, w) { - rows = append(rows, t.span(ln, t.P.Fg)) + // The router turn is model output: render it as markdown (bold, + // lists, headings, code) wrapped to the panel width. glamour applies + // its own inline foreground colors; we keep the opaque panel by + // fixing each line's background. On any failure renderMarkdown hands + // back the plain content, which still flows through this path fine. + rendered := renderMarkdown(e.Content, w) + for _, ln := range strings.Split(rendered, "\n") { + rows = append(rows, lipgloss.NewStyle().Background(t.P.Bg).Render(ln)) } if s := metricsSuffix(e.Metrics); s != "" { rows = append(rows, t.span(s, t.P.Faint)) From 46a71ee84c2c2c86a683792fce886f33953bc778 Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 20 Jun 2026 16:16:00 +0000 Subject: [PATCH 017/107] docs(backlog): record B5, G, E (markdown + go.mod fix) in RETRO Co-Authored-By: Claude Opus 4.8 --- BACKLOG.md | 8 ++++++-- RETRO.md | 6 ++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/BACKLOG.md b/BACKLOG.md index 1b94bcbb..043f9339 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -34,9 +34,13 @@ matching bullets below are superseded; only the noted follow-ups remain live: - **C-A2** stage-level plan checkpointing — `1a1b5cc` - **B §4** architect contradiction-check (display-only) — `eae0a0c` *(follow-up: decision emit hook + in-session L3 embedding)* - **D** dedicated `SourceFetched`/`LowQualityExtraction` events — `b098d87`; dynamic per-session egress allowlist — `027ff1f` *(follow-up: thread sessionId into `NetworkHostRule`; batch fetch-approval still unbuilt)* +- **B §5** reviewer static-first (runner + event + context-exclusion, live-wired) — `447fc7a` *(follow-up: a stage that emits `StaticFindingsRecordedEvent`)* +- **G** narration-lane lag — `eff8f8d` +- **E** tui-go go.mod directive fix — `1e6699a`; markdown rendering in chat turns — `da72ee5` -**Not yet started (verifiable, Kotlin):** B §5 reviewer static-first infra; D batch fetch-approval; B §2 plan-derived diff manifest; G narration-lane lag; H freestyle follow-ups. -**Blocked in this sandbox:** E tui-go items (Go toolchain absent); F live-QA gates + D §6 web approval client (need local model / SearXNG / network / GPU). +**Not yet started (verifiable, Kotlin):** D batch fetch-approval; B §2 plan-derived diff manifest; H freestyle follow-ups. +**§E tui-go (now UNBLOCKED — Go via `GOTOOLCHAIN=auto` go1.24.2):** session list / resume view; per-event render-matrix golden tests; approval ergonomics audit; plan comparison view; idea-board→profile promotion. +**Still blocked in this sandbox:** F live-QA gates + D §6 web approval client (need local model / SearXNG / GPU). --- diff --git a/RETRO.md b/RETRO.md index 3c06458e..5926c9fc 100644 --- a/RETRO.md +++ b/RETRO.md @@ -23,6 +23,10 @@ Nine tracks, each compiled + unit-tested green before commit. | `eae0a0c` | Architect contradiction-check (display-only): `PossibleContradictionFlaggedEvent` + L3-backed `ArchitectContradictionChecker` | B §4 | | `b098d87` | Dedicated `SourceFetchedEvent` / `LowQualityExtractionEvent` (promoted from `ToolExecutionCompletedEvent` metadata; additive), emitted from `SandboxedToolExecutor` | D | | `027ff1f` | Dynamic per-session egress allowlist: `EgressHostsGrantedEvent` + `EgressAllowlist` union helper + `EgressAllowlistProjection`; `NetworkHostRule` delegates to the union | D | +| `447fc7a` | Reviewer static-first: `StaticFindingsRecordedEvent` + `StaticAnalysisRunner` (injectable `CommandRunner`; compiler/ktlint/detekt parsers) + `excludeStaticFindingsFromReview` filter, live-wired into reviewer context assembly | B §5 | +| `eff8f8d` | Narration-lane lag: stale pause narrations dropped per-approval-request on resolve / per-session on resume (replay-safe) | G | +| `1e6699a` | tui-go `go.mod` `go 1.26` → `1.24.2` (1.26 toolchain unreleased/unfetchable) — unblocks Go build/test on released toolchains | E (enabler) | +| `da72ee5` | tui-go: markdown rendering for chat turns via `glamour` (dark, per-width memoized), plain-text fallback; other roles/UI untouched | E | ### Follow-ups created this pass (refiled into BACKLOG) - **C-A1 activation** — `planner.md` must emit a `brief_echo` block and `role_pipeline.toml` must set `briefEcho="true"` / `briefEchoSource` for the gate to fire live (model-dependent → live-QA). @@ -30,6 +34,8 @@ Nine tracks, each compiled + unit-tested green before commit. - **B §4 wiring** — `ArchitectContradictionChecker` is unwired: needs an architect-decision emit hook on the server side, and prior decisions embedded into L3 in-session (today they only land at session end). - **A §4 llama wiring** — `LlamaServerHealthProbe` is unregistered: an `HttpClient` + tokens/sec telemetry aren't exposed to the health-monitor scope yet (`// TODO(wiring)` in `Main.kt`). - **D egress wiring** — `NetworkHostRule` consults the session set via a pure helper but is fed `emptySet()`: `ToolCallAssessmentInput` carries no `sessionId`, so going live needs session context threaded into the assessment input (`// TODO(wiring)` in `NetworkHostRule.kt`). Batch fetch-approval (approve a source list → one `EgressHostsGrantedEvent`) still unbuilt — needs approval-flow surgery. +- **B §5 static_check stage** — runner/event/filter complete and the filter is live, but no workflow stage emits `StaticFindingsRecordedEvent` yet (`// TODO(wiring)` in `StaticAnalysisRunner.kt`); needs an event-emitting deterministic stage-type seam. +- **E toolchain** — tui-go now builds via auto-downloaded go1.24.2 (`GOTOOLCHAIN=auto`). The committed directive fix assumes 1.26 was unintended; bump if a specific newer release is actually wanted. ### Environment note - A `~/.gradle/gradle.properties` machine-local override was added on this box (`-Xmx768m`, `parallel=false`, kotlin daemon `-Xmx640m`) because the repo's `-Xmx4G` OOM-killed the foreground process on a 1.9 GB machine. **Not committed** — repo defaults intact for higher-RAM machines. From 1f7a5d96a701e0d35513eb1b63b886047153da30 Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 20 Jun 2026 16:22:27 +0000 Subject: [PATCH 018/107] feat(tui-go): session list / resume view (E) R opens a navigable overlay of recent sessions (GET /sessions: short id, status, workflow/stage, relative age); enter resumes via POST /sessions/{id}/resume, which replays onto the existing global /stream (no WS re-dial). Fetch/resume errors surface in the overlay, never panic. stdlib-only (ws.Client.HTTPBase derives the base URL). Co-Authored-By: Claude Opus 4.8 --- apps/tui-go/internal/app/model.go | 9 + apps/tui-go/internal/app/overlays.go | 2 + apps/tui-go/internal/app/sessions_overlay.go | 259 ++++++++++++++++++ .../internal/app/sessions_overlay_test.go | 152 ++++++++++ apps/tui-go/internal/app/update.go | 37 +++ apps/tui-go/internal/app/view.go | 4 +- apps/tui-go/internal/ws/client.go | 31 ++- 7 files changed, 483 insertions(+), 11 deletions(-) create mode 100644 apps/tui-go/internal/app/sessions_overlay.go create mode 100644 apps/tui-go/internal/app/sessions_overlay_test.go diff --git a/apps/tui-go/internal/app/model.go b/apps/tui-go/internal/app/model.go index 190274b0..59d79708 100644 --- a/apps/tui-go/internal/app/model.go +++ b/apps/tui-go/internal/app/model.go @@ -53,6 +53,7 @@ const ( OverlayConfig OverlayStats OverlayIdeas + OverlaySessions ) // RouterEntry is one line in a session's conversation transcript. @@ -259,6 +260,14 @@ type Model struct { ideasIndex int ideasLoading bool + // session browser (OverlaySessions) — recent sessions fetched over HTTP from + // GET /sessions, so prior runs are reachable after a server restart + TUI reopen + // (the WS stream only carries live sessions, not the historical roster). + sessionList []SessionSummary + sessionListIndex int + sessionListLoading bool + sessionListErr string + // command palette paletteFilter string paletteIndex int diff --git a/apps/tui-go/internal/app/overlays.go b/apps/tui-go/internal/app/overlays.go index 10cd6b4d..a9bc57ad 100644 --- a/apps/tui-go/internal/app/overlays.go +++ b/apps/tui-go/internal/app/overlays.go @@ -61,6 +61,8 @@ func (m Model) renderOverlay(base string) string { return m.center(m.statsModal()) case OverlayIdeas: return m.center(m.ideasModal()) + case OverlaySessions: + return m.center(m.sessionsModal()) } return base } diff --git a/apps/tui-go/internal/app/sessions_overlay.go b/apps/tui-go/internal/app/sessions_overlay.go new file mode 100644 index 00000000..6a4f73d1 --- /dev/null +++ b/apps/tui-go/internal/app/sessions_overlay.go @@ -0,0 +1,259 @@ +package app + +import ( + "encoding/json" + "io" + "net/http" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// SessionSummary is one recent session from GET /sessions. Mirrors the server's +// SessionSummaryResponse (apps/server SessionRoutes.kt) — the wire shape. +type SessionSummary struct { + SessionID string `json:"sessionId"` + Status string `json:"status"` + Intent string `json:"intent"` + WorkflowID string `json:"workflowId"` + StageCount int `json:"stageCount"` + CreatedAt string `json:"createdAt"` + LastActivityAt string `json:"lastActivityAt"` +} + +// sessionsLoadedMsg carries the result of a GET /sessions fetch. Exactly one of +// sessions / err is meaningful; err non-empty means the fetch failed and the +// overlay shows it instead of crashing. +type sessionsLoadedMsg struct { + sessions []SessionSummary + err string +} + +// sessionResumeMsg is the outcome of asking the server to resume a session +// (POST /sessions/{id}/resume). On success its events replay onto the live +// stream the app already drains; on failure the overlay shows the error. +type sessionResumeMsg struct { + sessionID string + err string +} + +// sessionsHTTPTimeout bounds the REST calls so a wedged server can't hang the UI. +const sessionsHTTPTimeout = 5 * time.Second + +// fetchSessions is a tea.Cmd that GETs the recent-session roster over HTTP and +// reports it as a sessionsLoadedMsg. base is the http://host:port origin (from +// the ws client); an empty base yields an error message rather than a panic. +func fetchSessions(base string) tea.Cmd { + return func() tea.Msg { + if base == "" { + return sessionsLoadedMsg{err: "no server address"} + } + client := &http.Client{Timeout: sessionsHTTPTimeout} + resp, err := client.Get(base + "/sessions") + if err != nil { + return sessionsLoadedMsg{err: "fetch failed: " + err.Error()} + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return sessionsLoadedMsg{err: "server returned " + resp.Status} + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return sessionsLoadedMsg{err: "read failed: " + err.Error()} + } + var out []SessionSummary + if err := json.Unmarshal(body, &out); err != nil { + return sessionsLoadedMsg{err: "decode failed: " + err.Error()} + } + return sessionsLoadedMsg{sessions: out} + } +} + +// resumeSession is a tea.Cmd that asks the server to rehydrate and relaunch a +// session (POST /sessions/{id}/resume). The server replays the rehydrated +// session onto the global stream this client already reads, so no WS re-dial is +// needed — the existing transport carries it. +func resumeSession(base, id string) tea.Cmd { + return func() tea.Msg { + if base == "" { + return sessionResumeMsg{sessionID: id, err: "no server address"} + } + client := &http.Client{Timeout: sessionsHTTPTimeout} + resp, err := client.Post(base+"/sessions/"+id+"/resume", "application/json", nil) + if err != nil { + return sessionResumeMsg{sessionID: id, err: "resume failed: " + err.Error()} + } + defer resp.Body.Close() + // 202 Accepted on success; 200 (already running) is fine too. + if resp.StatusCode != http.StatusAccepted && resp.StatusCode != http.StatusOK { + return sessionResumeMsg{sessionID: id, err: "server returned " + resp.Status} + } + return sessionResumeMsg{sessionID: id} + } +} + +// openSessions opens the recent-session browser and kicks off the HTTP fetch. +// It is global (not bound to the selected session), so it opens from anywhere — +// including the idle list after a fresh reconnect with no live sessions yet. +func (m *Model) openSessions() tea.Cmd { + m.overlay = OverlaySessions + m.sessionListIndex = 0 + m.sessionListErr = "" + m.sessionListLoading = true + return fetchSessions(m.client.HTTPBase()) +} + +// handleSessionsKey owns every key while the session browser is open: navigate +// the roster, esc closes, enter resumes the selected session. +func (m Model) handleSessionsKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { + switch { + case k.Type == tea.KeyEsc || runeIs(k, "R"): + m.overlay = OverlayNone + case k.Type == tea.KeyUp || runeIs(k, "k"): + if m.sessionListIndex > 0 { + m.sessionListIndex-- + } + case k.Type == tea.KeyDown || runeIs(k, "j"): + if m.sessionListIndex < len(m.sessionList)-1 { + m.sessionListIndex++ + } + case k.Type == tea.KeyEnter: + if m.sessionListIndex >= 0 && m.sessionListIndex < len(m.sessionList) { + return m.openSelectedSession() + } + } + return m, nil +} + +// openSelectedSession focuses the highlighted recent session locally and asks the +// server to resume it. Focusing it first means its replayed events land on the +// session the operator is now looking at; the resume cmd does the rest over the +// existing stream (no WS re-dial — see resumeSession). +func (m Model) openSelectedSession() (tea.Model, tea.Cmd) { + sum := m.sessionList[m.sessionListIndex] + s := m.ensureSession(sum.SessionID) + if sum.WorkflowID != "" { + s.WorkflowID = sum.WorkflowID + if s.Name == "" { + s.Name = sum.WorkflowID + } + } + if sum.Status != "" { + s.Status = sum.Status + } + m.selectedID = sum.SessionID + m.sessionEntered = true + m.overlay = OverlayNone + return m, resumeSession(m.client.HTTPBase(), sum.SessionID) +} + +func (m Model) sessionsModal() string { + t := m.theme + w := m.modalWidth() + + var b strings.Builder + b.WriteString(m.titleLine("sessions")) + if len(m.sessionList) > 0 { + b.WriteString(mbg(t, " ("+itoa(m.sessionListIndex+1)+"/"+itoa(len(m.sessionList))+")", t.P.Faint)) + } + b.WriteString("\n\n") + + if m.sessionListErr != "" { + b.WriteString(lipgloss.NewStyle().Foreground(t.P.Bad).Background(t.P.BgPanel).Render(" ▲ "+truncate(m.sessionListErr, w-8)) + "\n") + b.WriteString("\n" + modalHints(t, [][2]string{{"R/esc", "close"}})) + return t.Overlay.Width(w).Render(b.String()) + } + if m.sessionListLoading && len(m.sessionList) == 0 { + b.WriteString(mbg(t, " loading…", t.P.Faint) + "\n") + b.WriteString("\n" + modalHints(t, [][2]string{{"R/esc", "close"}})) + return t.Overlay.Width(w).Render(b.String()) + } + if len(m.sessionList) == 0 { + b.WriteString(mbg(t, " no recent sessions", t.P.Faint) + "\n") + b.WriteString("\n" + modalHints(t, [][2]string{{"R/esc", "close"}})) + return t.Overlay.Width(w).Render(b.String()) + } + + // Windowed roster, keeping the selected row in view. + bodyH := m.height*70/100 - 6 + if bodyH < 4 { + bodyH = 4 + } + off := 0 + if len(m.sessionList) > bodyH { + off = m.sessionListIndex - bodyH/2 + if off < 0 { + off = 0 + } + if off > len(m.sessionList)-bodyH { + off = len(m.sessionList) - bodyH + } + } + end := off + bodyH + if end > len(m.sessionList) { + end = len(m.sessionList) + } + for i := off; i < end; i++ { + b.WriteString(m.sessionListRow(i) + "\n") + } + + b.WriteString("\n" + modalHints(t, [][2]string{ + {"↑↓", "select"}, {"enter", "resume"}, {"R/esc", "close"}, + })) + return t.Overlay.Width(w).Render(b.String()) +} + +// sessionListRow renders one roster line: marker, short id, status, current +// stage (if any), and relative age of the last activity. +func (m Model) sessionListRow(i int) string { + t := m.theme + s := m.sessionList[i] + + marker := mbg(t, " ", t.P.BgPanel) + idFg := t.P.Fg + if i == m.sessionListIndex { + marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▸ ") + idFg = t.P.FgStrong + } + + idCell := lipgloss.NewStyle().Foreground(idFg).Background(t.P.BgPanel).Render(padRaw(shortSessionID(s.SessionID), 8)) + statusCell := lipgloss.NewStyle().Foreground(statusColor(t, s.Status)).Background(t.P.BgPanel).Bold(true). + Render(padRaw(statusLabel(s.Status), 9)) + + stage := s.WorkflowID + if s.StageCount > 0 { + stage += " ·" + itoa(s.StageCount) + "stg" + } + stageCell := mbg(t, padRaw(stage, 22), t.P.Accent2) + + age := relativeAge(s.LastActivityAt) + ageCell := mbg(t, age, t.P.Faint) + + return marker + idCell + " " + statusCell + " " + stageCell + " " + ageCell +} + +// shortSessionID trims a long session id to a recognizable prefix for the list. +func shortSessionID(id string) string { + if len(id) <= 8 { + return id + } + return id[:8] +} + +// relativeAge renders an ISO-8601 instant string (Instant.toString() on the +// server) as a compact "Ns ago" age, falling back to "—" when absent/unparseable. +func relativeAge(iso string) string { + if iso == "" { + return "—" + } + ts, err := time.Parse(time.RFC3339Nano, iso) + if err != nil { + ts, err = time.Parse(time.RFC3339, iso) + if err != nil { + return "—" + } + } + return agoLabel(nowMillis() - ts.UnixMilli()) +} diff --git a/apps/tui-go/internal/app/sessions_overlay_test.go b/apps/tui-go/internal/app/sessions_overlay_test.go new file mode 100644 index 00000000..83709458 --- /dev/null +++ b/apps/tui-go/internal/app/sessions_overlay_test.go @@ -0,0 +1,152 @@ +package app + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" +) + +func sessionsModel() Model { + m := NewModel(nil) + m.width, m.height = 120, 40 + m.theme = NewTheme(SoftBlue) + return m +} + +// sampleSummaries is a deterministic two-row roster used across the tests. +func sampleSummaries() []SessionSummary { + return []SessionSummary{ + {SessionID: "aaaaaaaa-1111", Status: "ACTIVE", WorkflowID: "refactor", StageCount: 2}, + {SessionID: "bbbbbbbb-2222", Status: "FAILED", WorkflowID: "healthcheck"}, + } +} + +func TestSessionsOverlayToggle(t *testing.T) { + m := sessionsModel() + // `R` opens the browser from the idle list. + updated, _ := m.handleNormalKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("R")}) + m = updated.(Model) + if m.overlay != OverlaySessions { + t.Fatalf("expected OverlaySessions after R, got %d", m.overlay) + } + if !m.sessionListLoading { + t.Fatalf("expected loading state right after open") + } + // `esc` closes it (routed through the overlay key handler). + updated, _ = m.handleOverlayKey(tea.KeyMsg{Type: tea.KeyEsc}) + m = updated.(Model) + if m.overlay != OverlayNone { + t.Fatalf("expected overlay closed after esc, got %d", m.overlay) + } +} + +func TestSessionsLoadedPopulatesAndRenders(t *testing.T) { + m := sessionsModel() + m.overlay = OverlaySessions + m.sessionListLoading = true + m.applySessionsLoaded(sessionsLoadedMsg{sessions: sampleSummaries()}) + if m.sessionListLoading { + t.Fatalf("expected loading cleared after load") + } + if len(m.sessionList) != 2 { + t.Fatalf("expected 2 sessions, got %d", len(m.sessionList)) + } + out := m.sessionsModal() + if !strings.Contains(out, "aaaaaaaa") || !strings.Contains(out, "bbbbbbbb") { + t.Fatalf("modal missing session ids:\n%s", out) + } + if !strings.Contains(out, "ACTIVE") || !strings.Contains(out, "FAILED") { + t.Fatalf("modal missing statuses:\n%s", out) + } +} + +func TestSessionsNavigationBoundsChecked(t *testing.T) { + m := sessionsModel() + m.overlay = OverlaySessions + m.sessionList = sampleSummaries() + // Up at the top is a no-op (clamped at 0). + updated, _ := m.handleSessionsKey(tea.KeyMsg{Type: tea.KeyUp}) + m = updated.(Model) + if m.sessionListIndex != 0 { + t.Fatalf("expected index clamped to 0 at top, got %d", m.sessionListIndex) + } + // Down moves to row 1. + updated, _ = m.handleSessionsKey(tea.KeyMsg{Type: tea.KeyDown}) + m = updated.(Model) + if m.sessionListIndex != 1 { + t.Fatalf("expected index 1 after down, got %d", m.sessionListIndex) + } + // Down again is clamped at the last row. + updated, _ = m.handleSessionsKey(tea.KeyMsg{Type: tea.KeyDown}) + m = updated.(Model) + if m.sessionListIndex != 1 { + t.Fatalf("expected index clamped to 1 at bottom, got %d", m.sessionListIndex) + } + // `j` is the vim-style alias for down (still clamped here). + updated, _ = m.handleSessionsKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")}) + m = updated.(Model) + if m.sessionListIndex != 1 { + t.Fatalf("expected j clamped at bottom, got %d", m.sessionListIndex) + } +} + +func TestSessionsEnterFocusesAndEmitsResume(t *testing.T) { + m := sessionsModel() + m.overlay = OverlaySessions + m.sessionList = sampleSummaries() + m.sessionListIndex = 1 // the FAILED healthcheck row + updated, cmd := m.handleSessionsKey(tea.KeyMsg{Type: tea.KeyEnter}) + m = updated.(Model) + if m.overlay != OverlayNone { + t.Fatalf("expected overlay closed after resume, got %d", m.overlay) + } + if m.selectedID != "bbbbbbbb-2222" { + t.Fatalf("expected selected id bbbbbbbb-2222, got %q", m.selectedID) + } + if !m.sessionEntered { + t.Fatalf("expected session entered after resume") + } + if s := m.session("bbbbbbbb-2222"); s == nil || s.WorkflowID != "healthcheck" { + t.Fatalf("expected vivified session with workflow healthcheck, got %+v", s) + } + // A resume cmd is emitted; with a nil client (no server addr) it resolves to a + // sessionResumeMsg carrying the error — exercised here so it can't panic. + if cmd == nil { + t.Fatalf("expected a resume cmd to be emitted") + } + if msg, ok := cmd().(sessionResumeMsg); !ok { + t.Fatalf("expected sessionResumeMsg from resume cmd, got %T", cmd()) + } else if msg.sessionID != "bbbbbbbb-2222" { + t.Fatalf("resume msg for wrong session: %q", msg.sessionID) + } +} + +func TestSessionsFetchErrorShownNoPanic(t *testing.T) { + m := sessionsModel() + m.overlay = OverlaySessions + m.sessionListLoading = true + m.applySessionsLoaded(sessionsLoadedMsg{err: "server returned 503 Service Unavailable"}) + if m.sessionListLoading { + t.Fatalf("expected loading cleared on error") + } + if m.sessionListErr == "" { + t.Fatalf("expected error recorded") + } + out := m.sessionsModal() + if !strings.Contains(out, "503") { + t.Fatalf("modal should surface the fetch error:\n%s", out) + } +} + +func TestRelativeAgeParsing(t *testing.T) { + if got := relativeAge(""); got != "—" { + t.Fatalf("empty age should be em-dash, got %q", got) + } + if got := relativeAge("not-a-timestamp"); got != "—" { + t.Fatalf("unparseable age should be em-dash, got %q", got) + } + if got := relativeAge("2020-01-01T00:00:00Z"); !strings.HasSuffix(got, "ago") { + t.Fatalf("a real instant should render an age, got %q", got) + } +} diff --git a/apps/tui-go/internal/app/update.go b/apps/tui-go/internal/app/update.go index 8d8c5edb..c8b54d40 100644 --- a/apps/tui-go/internal/app/update.go +++ b/apps/tui-go/internal/app/update.go @@ -51,12 +51,37 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.applyServerPhased(msg.m) return m, readServer(m.client) + case sessionsLoadedMsg: + m.applySessionsLoaded(msg) + return m, nil + + case sessionResumeMsg: + if msg.err != "" { + m.sessionListErr = "resume: " + msg.err + } + return m, nil + case tea.KeyMsg: return m.handleKey(msg) } return m, nil } +// applySessionsLoaded folds a GET /sessions result into the browser, clamping the +// cursor and surfacing any fetch error in place of a crash. +func (m *Model) applySessionsLoaded(msg sessionsLoadedMsg) { + m.sessionListLoading = false + if msg.err != "" { + m.sessionListErr = msg.err + return + } + m.sessionListErr = "" + m.sessionList = msg.sessions + if m.sessionListIndex >= len(m.sessionList) { + m.sessionListIndex = 0 + } +} + func (m *Model) applyConn(s ws.Status) { switch { case s.Connected: @@ -178,6 +203,10 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { m.openArtifacts() case "I": m.openIdeas() + case "R": + // Resume browser: recent sessions over HTTP, reachable even after a server + // restart + TUI reopen (the live stream only carries running sessions). + return m, m.openSessions() case "S": m.openStats() case "g": @@ -359,6 +388,11 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { if m.overlay == OverlayConfig { return m.handleConfigKey(k) } + // Sessions overlay owns all its keys: enter resumes (emits a cmd), so it can't + // share the generic no-cmd esc path below. + if m.overlay == OverlaySessions { + return m.handleSessionsKey(k) + } if k.Type == tea.KeyEsc { m.overlay = OverlayNone return m, nil @@ -566,6 +600,7 @@ func paletteCommands() []paletteCmd { {"events", "event inspector", "browse the event stream"}, {"artifacts", "view artifacts", "browse this session's artifacts"}, {"stats", "session stats", "metrics for the selected session"}, + {"sessions", "resume session", "browse / resume recent sessions"}, {"config", "edit config", "view / change correx settings"}, {"mode", "toggle mode", "switch chat / steering"}, {"cancel", "cancel session", "stop the selected session"}, @@ -605,6 +640,8 @@ func (m Model) execPalette(id string) (tea.Model, tea.Cmd) { m.openArtifacts() case "stats": m.openStats() + case "sessions": + return m, m.openSessions() case "config": m.openConfig() case "mode": diff --git a/apps/tui-go/internal/app/view.go b/apps/tui-go/internal/app/view.go index f5898920..b48eb377 100644 --- a/apps/tui-go/internal/app/view.go +++ b/apps/tui-go/internal/app/view.go @@ -232,9 +232,9 @@ func (m Model) renderFooter() string { hints = []string{hint("↑↓", "choose"), hint("enter", "launch"), hint("e", "custom"), hint("esc", "later")} case m.displayState() == StateIdle: if nrw { - hints = []string{hint("i", "name"), hint("/", "filter"), hint("w", "wf"), hint("p", "cmds"), hint("q", "quit")} + hints = []string{hint("i", "name"), hint("/", "filter"), hint("w", "wf"), hint("R", "resume"), hint("p", "cmds"), hint("q", "quit")} } else { - hints = []string{hint("i", "name"), hint("/", "filter"), hint("enter", "open"), hint("jk", "move"), hint("w", "workflows"), hint("p", "cmds"), hint("q", "quit")} + hints = []string{hint("i", "name"), hint("/", "filter"), hint("enter", "open"), hint("jk", "move"), hint("w", "workflows"), hint("R", "resume"), hint("p", "cmds"), hint("q", "quit")} } default: // StateInSession if nrw { diff --git a/apps/tui-go/internal/ws/client.go b/apps/tui-go/internal/ws/client.go index 7e59ccb6..4313ff04 100644 --- a/apps/tui-go/internal/ws/client.go +++ b/apps/tui-go/internal/ws/client.go @@ -28,23 +28,36 @@ type Status struct { // Client is a reconnecting WebSocket client. Construct with New, then Run in a // goroutine. Incoming and Conn are drained by the UI. type Client struct { - url string - send chan []byte - in chan protocol.ServerMessage - conn chan Status + url string + httpBase string + send chan []byte + in chan protocol.ServerMessage + conn chan Status } // New builds a client targeting ws://host:port/stream. func New(host string, port int) *Client { - u := url.URL{Scheme: "ws", Host: hostPort(host, port), Path: "/stream"} + hp := hostPort(host, port) + u := url.URL{Scheme: "ws", Host: hp, Path: "/stream"} return &Client{ - url: u.String(), - send: make(chan []byte, 64), - in: make(chan protocol.ServerMessage, 256), - conn: make(chan Status, 16), + url: u.String(), + httpBase: (&url.URL{Scheme: "http", Host: hp}).String(), + send: make(chan []byte, 64), + in: make(chan protocol.ServerMessage, 256), + conn: make(chan Status, 16), } } +// HTTPBase is the http://host:port origin the WS client connects to, for REST +// calls (e.g. GET /sessions) that share the server's host/port. Empty for a nil +// client (the test harness constructs Models with a nil client). +func (c *Client) HTTPBase() string { + if c == nil { + return "" + } + return c.httpBase +} + func hostPort(host string, port int) string { h := host if h == "" { From 2a99e1538313a08e0ca6112efd169679175e06a2 Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 20 Jun 2026 16:30:25 +0000 Subject: [PATCH 019/107] =?UTF-8?q?test(tui-go):=20per-event=20render-matr?= =?UTF-8?q?ix=20golden=20tests=20(E=20=C2=A72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Table-driven render assertions (37 cases over 42 protocol Type* constants), driven through applyServer and asserted ANSI-stripped on stable defining text. Coverage guard parses every Type* from protocol.go and fails if a kind is neither covered nor explicitly classified non-rendering. Pins width/theme/timestamp; no behavior change. Co-Authored-By: Claude Opus 4.8 --- .../tui-go/internal/app/render_matrix_test.go | 668 ++++++++++++++++++ 1 file changed, 668 insertions(+) create mode 100644 apps/tui-go/internal/app/render_matrix_test.go diff --git a/apps/tui-go/internal/app/render_matrix_test.go b/apps/tui-go/internal/app/render_matrix_test.go new file mode 100644 index 00000000..82c8b72b --- /dev/null +++ b/apps/tui-go/internal/app/render_matrix_test.go @@ -0,0 +1,668 @@ +package app + +import ( + "bufio" + "os" + "regexp" + "strings" + "testing" + + "github.com/correx/tui-go/internal/protocol" + "github.com/correx/tui-go/internal/ws" +) + +// render_matrix_test.go is the per-event-type golden render matrix (BACKLOG §E §2): +// one case per server event/entry kind the TUI handles. Each case drives a +// representative protocol.ServerMessage through the *production* path (applyServer), +// renders the surface it lands on (View / a card / a modal / the router transcript), +// strips ANSI, and asserts the render carries that kind's defining, stable text. +// +// Goldens are inline expected substrings rather than golden files: it matches the +// module's existing render-test convention (clarcard_test, ideacard_test, …), needs no +// -update dance, and pins the *meaning* of each render (the tool name, stage id, status +// label, card title) without coupling to exact lipgloss styling or timestamps. +// +// The coverage guard (TestRenderMatrixCoversEveryEventType) is the load-bearing part: +// it parses every protocol.Type* constant and fails if a new event kind is added +// without either a matrix case or an explicit non-rendering classification. + +// matrixSurface selects which render surface a case asserts against — each is a real +// production render entry point, fed off the same Model that applyServer mutated. +type matrixSurface int + +const ( + surfaceView matrixSurface = iota // full immediate-mode frame, View() + surfaceEvents // right-panel event-stream rows (eventRows) + surfaceRouter // left transcript rows (routerRows) + surfaceApprovalBand // docked approval band (renderApprovalBand) + surfaceClarModal // clarification modal + surfaceProposeModal // workflow-propose modal + surfaceIdeasModal // idea board overlay + surfaceStatsModal // session-stats overlay + surfaceConfigModal // config editor overlay + surfaceArtifactsModal // artifact viewer overlay + surfaceModelsModal // model swap overlay + surfaceStatusBar // top status bar (renderStatus) +) + +// fixedNow is a deterministic timestamp used for every event carrying OccurredAt, so +// formatTime output is stable across runs/machines. (Events that fall back to +// nowMillis() are asserted only on their type/detail text, never the clock.) +const fixedNow int64 = 1_700_000_000_000 + +// matrixCase is one event/entry kind in the matrix. +type matrixCase struct { + name string // human label / subtest name + kinds []string // protocol.Type* values this case exercises + build func() protocol.ServerMessage // a representative frame + surface matrixSurface // where its render lands + // prep mutates the model before applyServer (e.g. select + enter a session so an + // in-session surface is reachable, or open the overlay an *.list reply fills). + prep func(m *Model) + want []string // ANSI-stripped substrings the render must contain +} + +// newMatrixModel builds a deterministic Model: fixed width/theme, an entered session, +// an unconnected ws client (Send buffers, never dials). +func newMatrixModel() Model { + m := NewModel(ws.New("", 0)) + m.width, m.height = 120, 40 + m.theme = NewTheme(SoftBlue) + m.connected = true + m.selectedID = "s1" + m.sessionEntered = true + m.sessions = []Session{{ID: "s1", Status: "ACTIVE", Name: "healthcheck", LastEventAt: fixedNow}} + return m +} + +// renderSurface renders the requested surface off m and strips ANSI. +func renderSurface(m Model, s matrixSurface) string { + var raw string + switch s { + case surfaceView: + raw = m.View() + case surfaceEvents: + // Render the event-stream rows at full panel width so a row's detail isn't + // clipped at the slim right-panel edge (the production builder, just wide). + raw = strings.Join(m.eventRows(110, 40), "\n") + case surfaceRouter: + raw = strings.Join(m.routerRows(100, 30), "\n") + case surfaceApprovalBand: + raw = m.renderApprovalBand(m.approvalBandHeight()) + case surfaceClarModal: + raw = m.clarificationModal() + case surfaceProposeModal: + raw = m.proposeModal() + case surfaceIdeasModal: + raw = m.ideasModal() + case surfaceStatsModal: + raw = m.statsModal() + case surfaceConfigModal: + raw = m.configModal() + case surfaceArtifactsModal: + raw = m.artifactsModal() + case surfaceModelsModal: + raw = m.modelsModal() + case surfaceStatusBar: + raw = m.renderStatus() + } + return stripANSI(raw) +} + +// matrixCases is the matrix: one entry per event/entry kind the renderer handles. +func matrixCases() []matrixCase { + str := func(s string) *string { return &s } + return []matrixCase{ + // --- session lifecycle (status bar + narration feed) --- + { + name: "session.announced", + kinds: []string{protocol.TypeSessionAnnounced}, + surface: surfaceStatusBar, + build: func() protocol.ServerMessage { return msg(protocol.TypeSessionAnnounced, withWF("healthcheck")) }, + want: []string{"healthcheck", "ACTIVE"}, + }, + { + name: "session.paused", + kinds: []string{protocol.TypeSessionPaused}, + surface: surfaceRouter, + build: func() protocol.ServerMessage { return msg(protocol.TypeSessionPaused) }, + want: []string{"paused"}, + }, + { + name: "session.resumed", + kinds: []string{protocol.TypeSessionResumed}, + surface: surfaceRouter, + build: func() protocol.ServerMessage { return msg(protocol.TypeSessionResumed) }, + want: []string{"resumed"}, + }, + { + name: "session.completed", + kinds: []string{protocol.TypeSessionCompleted}, + surface: surfaceRouter, + build: func() protocol.ServerMessage { return msg(protocol.TypeSessionCompleted) }, + want: []string{"workflow complete"}, + }, + { + name: "session.failed", + kinds: []string{protocol.TypeSessionFailed}, + surface: surfaceRouter, + build: func() protocol.ServerMessage { return msg(protocol.TypeSessionFailed, withReason("schema mismatch")) }, + want: []string{"workflow failed", "schema mismatch"}, + }, + + // --- chat / router transcript --- + { + name: "chat.turn (router)", + kinds: []string{protocol.TypeChatTurn}, + surface: surfaceRouter, + build: func() protocol.ServerMessage { + return msg(protocol.TypeChatTurn, func(m *protocol.ServerMessage) { + m.Role, m.Content = "ROUTER", "running the healthcheck now" + }) + }, + want: []string{"running the healthcheck now"}, + }, + { + name: "router.narration", + kinds: []string{protocol.TypeRouterNarration}, + surface: surfaceRouter, + build: func() protocol.ServerMessage { + return msg(protocol.TypeRouterNarration, func(m *protocol.ServerMessage) { + m.Content = "moving on to validation" + }) + }, + want: []string{"moving on to validation", "◆"}, + }, + + // --- stage lifecycle (event stream rows) --- + { + name: "stage.started", + kinds: []string{protocol.TypeStageStarted}, + surface: surfaceEvents, + build: func() protocol.ServerMessage { return msg(protocol.TypeStageStarted, withStage("write_script")) }, + want: []string{"StageStarted", "write_script"}, + }, + { + name: "stage.completed", + kinds: []string{protocol.TypeStageCompleted}, + surface: surfaceEvents, + build: func() protocol.ServerMessage { return msg(protocol.TypeStageCompleted, withStage("write_script")) }, + want: []string{"StageCompleted", "write_script"}, + }, + { + name: "stage.failed", + kinds: []string{protocol.TypeStageFailed}, + surface: surfaceEvents, + build: func() protocol.ServerMessage { return msg(protocol.TypeStageFailed, withStage("generate")) }, + want: []string{"StageFailed", "generate"}, + }, + + // --- inference lifecycle (event stream rows) --- + { + name: "inference.started", + kinds: []string{protocol.TypeInferenceStarted}, + surface: surfaceEvents, + build: func() protocol.ServerMessage { return msg(protocol.TypeInferenceStarted, withStage("plan")) }, + want: []string{"InferenceStarted", "plan"}, + }, + { + name: "inference.completed", + kinds: []string{protocol.TypeInferenceDone}, + surface: surfaceEvents, + build: func() protocol.ServerMessage { + return msg(protocol.TypeInferenceDone, withStage("plan"), func(m *protocol.ServerMessage) { m.Summary = "ok" }) + }, + want: []string{"InferenceCompleted", "plan"}, + }, + { + name: "inference.timed_out", + kinds: []string{protocol.TypeInferenceTimeout}, + surface: surfaceEvents, + build: func() protocol.ServerMessage { return msg(protocol.TypeInferenceTimeout, withStage("plan")) }, + want: []string{"InferenceTimedOut", "plan"}, + }, + { + name: "inference.failed", + kinds: []string{protocol.TypeInferenceFailed}, + surface: surfaceEvents, + build: func() protocol.ServerMessage { + return msg(protocol.TypeInferenceFailed, withStage("plan"), withReason("oom")) + }, + want: []string{"InferenceFailed", "plan", "oom"}, + }, + { + name: "inference.retry", + kinds: []string{protocol.TypeInferenceRetry}, + surface: surfaceEvents, + build: func() protocol.ServerMessage { + return msg(protocol.TypeInferenceRetry, withStage("plan"), func(m *protocol.ServerMessage) { + m.AttemptNumber, m.MaxAttempts, m.FailureReason = 2, 3, "rate limited" + }) + }, + want: []string{"RetryAttempted", "plan", "2/3", "rate limited"}, + }, + + // --- tools --- + { + name: "tool.started", + kinds: []string{protocol.TypeToolStarted}, + surface: surfaceStatusBar, // started shows as the active spinner; record lands in s.Tools + build: func() protocol.ServerMessage { return msg(protocol.TypeToolStarted, withTool("file_write")) }, + want: []string{"active"}, + }, + { + name: "tool.completed", + kinds: []string{protocol.TypeToolCompleted}, + surface: surfaceEvents, + build: func() protocol.ServerMessage { + return msg(protocol.TypeToolCompleted, withTool("file_write"), func(m *protocol.ServerMessage) { m.Summary = "wrote 2 files" }) + }, + want: []string{"ToolCompleted", "file_write"}, + }, + { + name: "tool.failed", + kinds: []string{protocol.TypeToolFailed}, + surface: surfaceView, + prep: func(m *Model) { + // failed marks an already-started tool record; seed one so the status flips. + s := m.session("s1") + s.Tools = []ToolRecord{{Name: "file_write", Status: ToolStarted}} + }, + build: func() protocol.ServerMessage { return msg(protocol.TypeToolFailed, withTool("file_write")) }, + // tool.failed is a state-only mutation (no event row, no card); assert it + // renders the in-session frame without crashing and keeps the session header. + want: []string{"healthcheck"}, + }, + { + name: "tool.rejected", + kinds: []string{protocol.TypeToolRejected}, + surface: surfaceView, + prep: func(m *Model) { + s := m.session("s1") + s.Tools = []ToolRecord{{Name: "shell_exec", Status: ToolStarted}} + }, + build: func() protocol.ServerMessage { return msg(protocol.TypeToolRejected, withTool("shell_exec")) }, + want: []string{"healthcheck"}, + }, + { + name: "tool.assessed", + kinds: []string{protocol.TypeToolAssessed}, + surface: surfaceEvents, + build: func() protocol.ServerMessage { + return msg(protocol.TypeToolAssessed, withTool("file_write"), func(m *protocol.ServerMessage) { + m.Disposition = "BLOCK" + m.AssessedIssues = []protocol.AssessedIssueDto{{Code: "PATH_ESCAPE", Message: "outside workspace", Severity: "HIGH"}} + }) + }, + want: []string{"ToolAssessed", "BLOCK", "PATH_ESCAPE"}, + }, + + // --- artifacts / plan (event stream rows) --- + { + name: "artifact.created", + kinds: []string{protocol.TypeArtifactCreated}, + surface: surfaceEvents, + build: func() protocol.ServerMessage { + return msg(protocol.TypeArtifactCreated, func(m *protocol.ServerMessage) { m.ArtifactID = "art-7" }) + }, + want: []string{"ArtifactCreated", "art-7"}, + }, + { + name: "artifact.validated", + kinds: []string{protocol.TypeArtifactValid}, + surface: surfaceEvents, + build: func() protocol.ServerMessage { + return msg(protocol.TypeArtifactValid, func(m *protocol.ServerMessage) { m.ArtifactID = "art-7" }) + }, + want: []string{"ArtifactValidated", "art-7"}, + }, + { + name: "plan.locked", + kinds: []string{protocol.TypePlanLocked}, + surface: surfaceEvents, + build: func() protocol.ServerMessage { + return msg(protocol.TypePlanLocked, func(m *protocol.ServerMessage) { m.StageIDs = []string{"plan", "build", "verify"} }) + }, + want: []string{"PlanLocked", "plan", "build", "verify"}, + }, + + // --- workspace bind (status bar) --- + { + name: "session.workspace_bound", + kinds: []string{protocol.TypeWorkspaceBound}, + surface: surfaceStatusBar, + build: func() protocol.ServerMessage { + return msg(protocol.TypeWorkspaceBound, func(m *protocol.ServerMessage) { m.WorkspaceRoot = "/tmp/corx-ws" }) + }, + want: []string{"corx-ws"}, + }, + + // --- approval gate (docked band card) --- + { + name: "approval.required", + kinds: []string{protocol.TypeApprovalRequired}, + surface: surfaceApprovalBand, + build: func() protocol.ServerMessage { + return msg(protocol.TypeApprovalRequired, withTool("file_write"), func(m *protocol.ServerMessage) { + m.RequestID, m.Tier = "req-1", "T3" + m.Preview = str("--- a/x\n+++ b/x\n@@ -1 +1 @@\n-old\n+new\n") + m.RiskSummary = &protocol.RiskSummaryDto{Level: "HIGH", Rationale: []string{"[PATH_OUTSIDE_WORKSPACE] /etc/hosts"}} + }) + }, + want: []string{"file_write", "T3", "HIGH", "PATH_OUTSIDE_WORKSPACE"}, + }, + { + name: "approval.resolved", + kinds: []string{protocol.TypeApprovalResolved}, + surface: surfaceEvents, + build: func() protocol.ServerMessage { + return msg(protocol.TypeApprovalResolved, func(m *protocol.ServerMessage) { m.Outcome, m.Reason = "APPROVED", "operator ok" }) + }, + want: []string{"ApprovalResolved", "APPROVED", "operator ok"}, + }, + + // --- clarification (modal card) --- + { + name: "clarification.required", + kinds: []string{protocol.TypeClarification}, + surface: surfaceClarModal, + build: func() protocol.ServerMessage { + return msg(protocol.TypeClarification, withStage("analyst"), func(m *protocol.ServerMessage) { + m.RequestID = "req-1" + m.Questions = []protocol.ClarificationQuestionDto{ + {ID: "stack", Prompt: "Which stack?", Options: []string{"React", "Vue"}, Header: "Stack"}, + } + }) + }, + want: []string{"clarifying questions", "Which stack?", "React", "Vue", "Stack"}, + }, + + // --- workflow proposal (modal card) --- + { + name: "workflow.proposed", + kinds: []string{protocol.TypeWorkflowProposed}, + surface: surfaceProposeModal, + build: func() protocol.ServerMessage { + return msg(protocol.TypeWorkflowProposed, func(m *protocol.ServerMessage) { + m.ProposalID, m.Prompt, m.OriginalRequest = "prop-1", "Run one of these?", "scan the repo" + m.Candidates = []protocol.ProposedWorkflowDto{ + {WorkflowID: "research", Reason: "gather + report"}, + {WorkflowID: "role_pipeline", Reason: "full build"}, + } + }) + }, + want: []string{"workflow suggestion", "Run one of these?", "research", "gather + report", "role_pipeline"}, + }, + + // --- idea board (overlay; cross-session, no session scope) --- + { + name: "idea.list", + kinds: []string{protocol.TypeIdeaList}, + surface: surfaceIdeasModal, + prep: func(m *Model) { m.overlay = OverlayIdeas }, + build: func() protocol.ServerMessage { + return protocol.ServerMessage{Type: protocol.TypeIdeaList, Ideas: []protocol.IdeaDto{ + {IdeaID: "i1", Text: "cache the repo map", CapturedAtMs: 1000}, + }} + }, + want: []string{"ideas", "cache the repo map"}, + }, + + // --- session stats (overlay) --- + { + name: "session.stats", + kinds: []string{protocol.TypeSessionStats}, + surface: surfaceStatsModal, + prep: func(m *Model) { m.overlay = OverlayStats; m.statsFor = "s1" }, + build: func() protocol.ServerMessage { + return protocol.ServerMessage{Type: protocol.TypeSessionStats, SessionID: "s1", Stats: sampleStats()} + }, + want: []string{"file_write", "read_file"}, + }, + + // --- config snapshot (overlay) --- + { + name: "config.snapshot", + kinds: []string{protocol.TypeConfigSnapshot}, + surface: surfaceConfigModal, + prep: func(m *Model) { m.overlay = OverlayConfig }, + build: func() protocol.ServerMessage { + return protocol.ServerMessage{Type: protocol.TypeConfigSnapshot, ConfigFields: []protocol.ConfigFieldDto{ + {Key: "router.model", Type: "string", Value: "qwen2.5-coder"}, + }} + }, + want: []string{"router.model", "qwen2.5-coder"}, + }, + + // --- artifact list (overlay) --- + { + name: "artifact.list", + kinds: []string{protocol.TypeArtifactList}, + surface: surfaceArtifactsModal, + prep: func(m *Model) { m.overlay = OverlayArtifacts; m.artifactsFor = "s1" }, + build: func() protocol.ServerMessage { + return protocol.ServerMessage{Type: protocol.TypeArtifactList, SessionID: "s1", Artifacts: []protocol.ArtifactDto{ + {ArtifactID: "art-7", StageID: "plan", SchemaVersion: 1, Phase: "PLAN", Content: str("{\"ok\":true}")}, + }} + }, + want: []string{"art-7"}, + }, + + // --- model list / changed (overlay + status chrome) --- + { + name: "model.list", + kinds: []string{protocol.TypeModelList}, + surface: surfaceModelsModal, + prep: func(m *Model) { m.overlay = OverlayModels }, + build: func() protocol.ServerMessage { + return protocol.ServerMessage{Type: protocol.TypeModelList, Models: []string{"qwen2.5-coder", "llama3"}, Current: "qwen2.5-coder"} + }, + want: []string{"qwen2.5-coder", "llama3"}, + }, + { + name: "model.changed", + kinds: []string{protocol.TypeModelChanged}, + surface: surfaceStatusBar, + build: func() protocol.ServerMessage { + return protocol.ServerMessage{Type: protocol.TypeModelChanged, ModelID: "llama3-swapped", Loaded: true} + }, + want: []string{"llama3-swapped"}, + }, + + // --- provider status (status bar model + locality) --- + { + name: "provider.status_changed", + kinds: []string{protocol.TypeProviderStatus}, + surface: surfaceStatusBar, + build: func() protocol.ServerMessage { + return protocol.ServerMessage{Type: protocol.TypeProviderStatus, ProviderID: "llama-cpp:qwen"} + }, + want: []string{"llama-cpp:qwen", "local"}, + }, + + // --- resource gauge (status bar) --- + { + name: "resource.status", + kinds: []string{protocol.TypeResourceStatus}, + surface: surfaceStatusBar, + prep: func(m *Model) { m.selectedID = ""; m.sessionEntered = false }, // gauge shows on the idle bar + build: func() protocol.ServerMessage { + used, total, util := int64(4096), int64(8192), 42 + return protocol.ServerMessage{Type: protocol.TypeResourceStatus, + GpuMemoryUsedMb: &used, GpuMemoryTotalMb: &total, GpuUtilizationPct: &util} + }, + want: []string{"VRAM 4096/8192M", "GPU 42%"}, + }, + + // --- workflow list (idle left pane) --- + { + name: "workflow.list", + kinds: []string{protocol.TypeWorkflowList}, + surface: surfaceView, + prep: func(m *Model) { m.selectedID = ""; m.sessionEntered = false; m.wfVisible = true; m.wfIndex = 0 }, + build: func() protocol.ServerMessage { + return protocol.ServerMessage{Type: protocol.TypeWorkflowList, Workflows: []protocol.WorkflowDto{ + {WorkflowID: "healthcheck", Description: "kick the tires"}, + }} + }, + want: []string{"healthcheck", "kick the tires"}, + }, + + // --- session snapshot (reopen: rebuilds a session from the recent-events tail) --- + { + name: "session_snapshot", + kinds: []string{protocol.TypeSessionSnapshot}, + surface: surfaceEvents, + prep: func(m *Model) { m.sessions = nil; m.selectedID = "snap1"; m.sessionEntered = true }, + build: func() protocol.ServerMessage { + return protocol.ServerMessage{ + Type: protocol.TypeSessionSnapshot, SessionID: "snap1", WorkflowID: "refactor", + State: &protocol.SessionStateDto{Status: "PAUSED"}, + RecentEvents: []protocol.EventEntryDto{{Timestamp: fixedNow, Type: "StageStarted", Detail: "plan"}}, + } + }, + want: []string{"StageStarted", "plan"}, + }, + + // --- stage tool manifest (declared, not-yet-run tools — state only) --- + { + name: "stage.tool_manifest", + kinds: []string{protocol.TypeStageManifest}, + surface: surfaceView, + build: func() protocol.ServerMessage { + return protocol.ServerMessage{Type: protocol.TypeStageManifest, SessionID: "s1", Stages: []protocol.StageToolDecl{ + {StageID: "plan", Tools: []protocol.ToolDecl{{Name: "read_file", Tier: 1}}}, + }} + }, + // manifest seeds s.ToolsByStage (surfaced by the tool palette, not the main + // frame); assert the in-session frame still renders cleanly with the session. + want: []string{"healthcheck"}, + }, + + // --- unknown / future event: raw fallback row, never dropped (§2) --- + { + name: "unknown event (raw fallback)", + kinds: []string{"some.future.event"}, + surface: surfaceEvents, + build: func() protocol.ServerMessage { + return protocol.ServerMessage{Type: "some.future.event", SessionID: "s1", OccurredAt: fixedNow} + }, + want: []string{"some.future.event", "raw"}, + }, + } +} + +// --- frame builders (keep cases terse) --- + +type msgOpt func(*protocol.ServerMessage) + +func msg(typ string, opts ...msgOpt) protocol.ServerMessage { + m := protocol.ServerMessage{Type: typ, SessionID: "s1", OccurredAt: fixedNow} + for _, o := range opts { + o(&m) + } + return m +} + +func withWF(id string) msgOpt { return func(m *protocol.ServerMessage) { m.WorkflowID = id } } +func withStage(id string) msgOpt { return func(m *protocol.ServerMessage) { m.StageID = id } } +func withTool(name string) msgOpt { return func(m *protocol.ServerMessage) { m.ToolName = name } } +func withReason(r string) msgOpt { return func(m *protocol.ServerMessage) { m.Reason = r } } + +// TestRenderMatrix is the table-driven golden render matrix: every event/entry kind, +// driven through applyServer, asserts its render carries its defining stable text. +func TestRenderMatrix(t *testing.T) { + for _, c := range matrixCases() { + t.Run(c.name, func(t *testing.T) { + m := newMatrixModel() + if c.prep != nil { + c.prep(&m) + } + m.applyServer(c.build()) + out := renderSurface(m, c.surface) + for _, want := range c.want { + if !strings.Contains(out, want) { + t.Fatalf("render for %q missing %q\n--- visible ---\n%s", c.name, want, out) + } + } + }) + } +} + +// nonRenderingEventTypes are protocol.Type* constants the TUI handles but that have no +// per-kind render of their own — they are explicitly classified here so the coverage +// guard stays exhaustive. Adding a new Type* constant forces a choice: give it a matrix +// case, or list it here with a reason. Either way the guard can't silently pass. +var nonRenderingEventTypes = map[string]string{ + protocol.TypeSnapshotComplete: "control frame: ends the snapshot-buffering phase; no render", + protocol.TypeRouterResponse: "recognized no-op: superseded by chat.turn on the global stream", + protocol.TypeProtocolError: "transport diagnostic: explicit no-op, not surfaced", +} + +// TestRenderMatrixCoversEveryEventType is the load-bearing matrix guarantee: it scans +// every protocol.Type* constant declared in protocol.go and fails if any is neither +// covered by a matrix case nor explicitly classified as non-rendering. A new event kind +// added to the protocol without a matching render assertion breaks this test. +func TestRenderMatrixCoversEveryEventType(t *testing.T) { + declared := declaredEventTypes(t) + if len(declared) < 30 { + t.Fatalf("expected to scan ~40 protocol Type* constants, found only %d — parser drift?", len(declared)) + } + + covered := map[string]bool{} + for _, c := range matrixCases() { + for _, k := range c.kinds { + covered[k] = true + } + } + + var missing []string + for typ := range declared { + if covered[typ] { + continue + } + if _, ok := nonRenderingEventTypes[typ]; ok { + continue + } + missing = append(missing, typ) + } + if len(missing) > 0 { + t.Fatalf("event types with no matrix case and no non-rendering classification: %v\n"+ + "add a case to matrixCases() (preferred) or list it in nonRenderingEventTypes with a reason", missing) + } + + // Reverse guard: every classified non-rendering type must still be a real constant + // (catches a typo or a removed constant rotting the allowlist). + for typ := range nonRenderingEventTypes { + if !declared[typ] { + t.Errorf("nonRenderingEventTypes lists %q which is not a declared protocol Type* value", typ) + } + } +} + +// declaredEventTypes parses protocol.go and returns the value of every Type* string +// constant (e.g. "stage.started"). Deriving the authoritative set from source — rather +// than a hand-kept list — is what makes the coverage guard catch *new* event kinds. +func declaredEventTypes(t *testing.T) map[string]bool { + t.Helper() + const src = "../protocol/protocol.go" + f, err := os.Open(src) + if err != nil { + t.Fatalf("open %s: %v", src, err) + } + defer f.Close() + + // Matches lines like: TypeStageStarted = "stage.started" + re := regexp.MustCompile(`^\s*Type[A-Za-z0-9]+\s*=\s*"([^"]+)"`) + out := map[string]bool{} + sc := bufio.NewScanner(f) + for sc.Scan() { + if mm := re.FindStringSubmatch(sc.Text()); mm != nil { + out[mm[1]] = true + } + } + if err := sc.Err(); err != nil { + t.Fatalf("scan %s: %v", src, err) + } + return out +} From 35596dc723daa69badadbf422f33f425c6f4f440 Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 20 Jun 2026 16:31:08 +0000 Subject: [PATCH 020/107] docs(backlog): record E session-list + render-matrix in RETRO Co-Authored-By: Claude Opus 4.8 --- BACKLOG.md | 4 ++-- RETRO.md | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/BACKLOG.md b/BACKLOG.md index 043f9339..c5da2e87 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -36,10 +36,10 @@ matching bullets below are superseded; only the noted follow-ups remain live: - **D** dedicated `SourceFetched`/`LowQualityExtraction` events — `b098d87`; dynamic per-session egress allowlist — `027ff1f` *(follow-up: thread sessionId into `NetworkHostRule`; batch fetch-approval still unbuilt)* - **B §5** reviewer static-first (runner + event + context-exclusion, live-wired) — `447fc7a` *(follow-up: a stage that emits `StaticFindingsRecordedEvent`)* - **G** narration-lane lag — `eff8f8d` -- **E** tui-go go.mod directive fix — `1e6699a`; markdown rendering in chat turns — `da72ee5` +- **E** tui-go go.mod directive fix — `1e6699a`; markdown rendering — `da72ee5`; session list / resume view — `1f7a5d9`; per-event render-matrix golden tests — `2a99e15` **Not yet started (verifiable, Kotlin):** D batch fetch-approval; B §2 plan-derived diff manifest; H freestyle follow-ups. -**§E tui-go (now UNBLOCKED — Go via `GOTOOLCHAIN=auto` go1.24.2):** session list / resume view; per-event render-matrix golden tests; approval ergonomics audit; plan comparison view; idea-board→profile promotion. +**§E tui-go remaining (UNBLOCKED — Go via `GOTOOLCHAIN=auto` go1.24.2):** approval ergonomics audit; plan comparison view; idea-board→profile promotion. **Still blocked in this sandbox:** F live-QA gates + D §6 web approval client (need local model / SearXNG / GPU). --- diff --git a/RETRO.md b/RETRO.md index 5926c9fc..ffce11a2 100644 --- a/RETRO.md +++ b/RETRO.md @@ -27,6 +27,8 @@ Nine tracks, each compiled + unit-tested green before commit. | `eff8f8d` | Narration-lane lag: stale pause narrations dropped per-approval-request on resolve / per-session on resume (replay-safe) | G | | `1e6699a` | tui-go `go.mod` `go 1.26` → `1.24.2` (1.26 toolchain unreleased/unfetchable) — unblocks Go build/test on released toolchains | E (enabler) | | `da72ee5` | tui-go: markdown rendering for chat turns via `glamour` (dark, per-width memoized), plain-text fallback; other roles/UI untouched | E | +| `1f7a5d9` | tui-go session list / resume view: `R` overlay over `GET /sessions`, resume via `POST /sessions/{id}/resume` onto the existing global stream | E | +| `2a99e15` | tui-go per-event render-matrix golden tests (37 cases) + a coverage guard that parses `protocol.go` so any new event type without a golden fails | E §2 | ### Follow-ups created this pass (refiled into BACKLOG) - **C-A1 activation** — `planner.md` must emit a `brief_echo` block and `role_pipeline.toml` must set `briefEcho="true"` / `briefEchoSource` for the gate to fire live (model-dependent → live-QA). From d5afcd345a661bfcbd1b5e0aff1d23cf9db5b0be Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 20 Jun 2026 16:40:49 +0000 Subject: [PATCH 021/107] =?UTF-8?q?feat(tui-go):=20approval=20ergonomics?= =?UTF-8?q?=20=E2=80=94=20queue=20+=20tier-gated=20confirm=20(E=20=C2=A73)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pending approvals are a navigable queue (PendingQueue/PendingIdx; ↑↓/jk, count shown), no modal stacking; resolve removes the specific gate by request id - y/n/e keys (approve/reject/steer); T0–T2 act on one key, T3+ requires arm+confirm ("press y again"), any other key disarms; reject/steer/auto never gated - snapshot restore now rehydrates the full pending list, not just the first Co-Authored-By: Claude Opus 4.8 --- apps/tui-go/internal/app/approval_test.go | 257 ++++++++++++++++++++++ apps/tui-go/internal/app/demo.go | 12 +- apps/tui-go/internal/app/model.go | 110 ++++++++- apps/tui-go/internal/app/overlays.go | 22 +- apps/tui-go/internal/app/server.go | 24 +- apps/tui-go/internal/app/update.go | 111 +++++++--- 6 files changed, 483 insertions(+), 53 deletions(-) create mode 100644 apps/tui-go/internal/app/approval_test.go diff --git a/apps/tui-go/internal/app/approval_test.go b/apps/tui-go/internal/app/approval_test.go new file mode 100644 index 00000000..072f549c --- /dev/null +++ b/apps/tui-go/internal/app/approval_test.go @@ -0,0 +1,257 @@ +package app + +import ( + "encoding/json" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/correx/tui-go/internal/protocol" + "github.com/correx/tui-go/internal/ws" +) + +// approval_test.go covers the approval-ergonomics gaps (BACKLOG §E §3): single-key +// approve/reject/steer for low tiers, an arm→confirm gate for T3+, and a navigable +// pending-approval queue (never modal-stacked). Deterministic: an unconnected ws +// client buffers Sent frames, Drain reads them back. + +// approvalModel builds an entered session and queues each given approval through the +// production applyServer path, so the queue/Pending state matches real event flow. +func approvalModel(approvals ...protocol.ServerMessage) (Model, *ws.Client) { + client := ws.New("", 0) // unconnected; Send buffers, Drain reads it back + m := NewModel(client) + m.width, m.height = 120, 40 + m.theme = NewTheme(SoftBlue) + m.selectedID = "s1" + m.sessionEntered = true + m.sessions = []Session{{ID: "s1", Status: "ACTIVE", Name: "healthcheck", LastEventAt: fixedNow}} + for _, a := range approvals { + m.applyServer(a) + } + return m, client +} + +// apprReq builds an approval.required frame for the given request id and tier. +func apprReq(reqID, tier string) protocol.ServerMessage { + return msg(protocol.TypeApprovalRequired, withTool("file_write"), func(m *protocol.ServerMessage) { + m.RequestID, m.Tier = reqID, tier + m.RiskSummary = &protocol.RiskSummaryDto{Level: "LOW"} + }) +} + +// applyKey drives a key through the production handleKey entry and returns the model. +func applyKey(m Model, k tea.KeyMsg) Model { + updated, _ := m.handleKey(k) + return updated.(Model) +} + +func runeKey(r rune) tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}} } + +// decodeApproval pulls the single ApprovalResponse frame off the client and returns +// its request id + decision. Fails if there isn't exactly one decision frame. +func decodeApproval(t *testing.T, client *ws.Client) (requestID, decision string) { + t.Helper() + var resp map[string]any + n := 0 + for _, f := range client.Drain() { + var raw map[string]any + if err := json.Unmarshal(f, &raw); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if raw["type"] == "com.correx.apps.server.protocol.ClientMessage.ApprovalResponse" { + resp = raw + n++ + } + } + if n != 1 { + t.Fatalf("expected exactly one ApprovalResponse frame, got %d", n) + } + rid, _ := resp["requestId"].(string) + dec, _ := resp["decision"].(string) + return rid, dec +} + +// noApprovalSent asserts the client buffered no ApprovalResponse frame. +func noApprovalSent(t *testing.T, client *ws.Client) { + t.Helper() + for _, f := range client.Drain() { + var raw map[string]any + _ = json.Unmarshal(f, &raw) + if raw["type"] == "com.correx.apps.server.protocol.ClientMessage.ApprovalResponse" { + t.Fatalf("did not expect an ApprovalResponse frame yet") + } + } +} + +func TestApprovalLowTierApprovesImmediately(t *testing.T) { + m, client := approvalModel(apprReq("req-1", "T1")) + if m.displayState() != StateApproval { + t.Fatalf("want StateApproval, got %v", m.displayState()) + } + m = applyKey(m, runeKey('y')) + rid, dec := decodeApproval(t, client) + if rid != "req-1" || dec != "APPROVE" { + t.Fatalf("want approve req-1, got %s/%s", rid, dec) + } + if s := m.session("s1"); s == nil || s.Pending != nil { + t.Fatalf("pending gate should be cleared after a low-tier approve") + } + if m.approvalArmed { + t.Fatalf("low-tier approve must not leave an armed confirm") + } +} + +func TestApprovalHighTierRequiresSecondConfirm(t *testing.T) { + m, client := approvalModel(apprReq("req-1", "T3")) + // First `y` arms; nothing is sent yet. + m = applyKey(m, runeKey('y')) + if !m.approvalArmed { + t.Fatalf("T3 approve should arm a confirm, not send") + } + noApprovalSent(t, client) + if s := m.session("s1"); s == nil || s.Pending == nil { + t.Fatalf("gate must remain pending while armed") + } + // Second `y` confirms and sends. + m = applyKey(m, runeKey('y')) + rid, dec := decodeApproval(t, client) + if rid != "req-1" || dec != "APPROVE" { + t.Fatalf("want approve req-1 after confirm, got %s/%s", rid, dec) + } + if m.approvalArmed { + t.Fatalf("arm should clear after the confirming keypress") + } +} + +func TestApprovalHighTierArmCancelledByOtherKey(t *testing.T) { + m, client := approvalModel(apprReq("req-1", "T3")) + m = applyKey(m, runeKey('y')) // arm + if !m.approvalArmed { + t.Fatalf("expected armed after first y") + } + // A non-confirm key (here `n` reject) cancels the arm. The arm itself must be + // gone, and no spurious APPROVE may slip through. + m = applyKey(m, runeKey('n')) + if m.approvalArmed { + t.Fatalf("arm should be cancelled by a non-confirm key") + } + rid, dec := decodeApproval(t, client) + if dec != "REJECT" || rid != "req-1" { + t.Fatalf("non-confirm key should route to its own action (reject), got %s/%s", rid, dec) + } +} + +func TestApprovalHighTierArmCancelledByNavKey(t *testing.T) { + // Two T3 gates: arming then pressing ↓ must disarm and only move the cursor — + // no decision may be emitted. + m, client := approvalModel(apprReq("req-1", "T3"), apprReq("req-2", "T3")) + m = applyKey(m, runeKey('y')) // arm req-1 + m = applyKey(m, tea.KeyMsg{Type: tea.KeyDown}) + if m.approvalArmed { + t.Fatalf("nav key should disarm") + } + noApprovalSent(t, client) + if s := m.session("s1"); s == nil || s.PendingIdx != 1 { + t.Fatalf("expected cursor advanced to index 1, got %d", s.PendingIdx) + } +} + +func TestApprovalQueueNavigatesAndCounts(t *testing.T) { + m, _ := approvalModel(apprReq("req-1", "T1"), apprReq("req-2", "T1"), apprReq("req-3", "T1")) + s := m.session("s1") + if s == nil || len(s.PendingQueue) != 3 { + t.Fatalf("expected 3 queued approvals, got %d", len(s.PendingQueue)) + } + // The band shows a "1/3" count and the current request, never stacked modals. + out := stripANSI(m.renderApprovalBand(m.approvalBandHeight())) + if !strings.Contains(out, "1/3") { + t.Fatalf("band should show queue count 1/3:\n%s", out) + } + // ↓ advances the selection; Pending follows the cursor. + m = applyKey(m, tea.KeyMsg{Type: tea.KeyDown}) + s = m.session("s1") + if s.PendingIdx != 1 || s.Pending.RequestID != "req-2" { + t.Fatalf("↓ should select req-2, got idx=%d id=%s", s.PendingIdx, s.Pending.RequestID) + } + out = stripANSI(m.renderApprovalBand(m.approvalBandHeight())) + if !strings.Contains(out, "2/3") { + t.Fatalf("band should show 2/3 after ↓:\n%s", out) + } + // ↑ wraps back. + m = applyKey(m, tea.KeyMsg{Type: tea.KeyUp}) + if m.session("s1").PendingIdx != 0 { + t.Fatalf("↑ should return to index 0") + } +} + +func TestApprovalResolveFrontAdvancesQueue(t *testing.T) { + m, client := approvalModel(apprReq("req-1", "T1"), apprReq("req-2", "T1")) + // Approve the front gate (low tier → immediate). + m = applyKey(m, runeKey('y')) + if rid, dec := decodeApproval(t, client); rid != "req-1" || dec != "APPROVE" { + t.Fatalf("want approve req-1, got %s/%s", rid, dec) + } + s := m.session("s1") + if s == nil || len(s.PendingQueue) != 1 || s.Pending.RequestID != "req-2" { + t.Fatalf("resolving the front gate should advance to req-2, got %+v", s.PendingQueue) + } + // A server-side resolve of the remaining gate empties the queue and exits approval. + m.applyServer(msg(protocol.TypeApprovalResolved, func(mm *protocol.ServerMessage) { + mm.RequestID, mm.Outcome = "req-2", "APPROVED" + })) + if s := m.session("s1"); s == nil || s.Pending != nil || len(s.PendingQueue) != 0 { + t.Fatalf("server resolve should clear the last gate") + } + if m.displayState() != StateInSession { + t.Fatalf("empty queue should leave approval state, got %v", m.displayState()) + } +} + +func TestApprovalServerResolveRemovesSpecificGate(t *testing.T) { + // A resolve for the *back* gate must drop only that one and keep the front shown. + m, _ := approvalModel(apprReq("req-1", "T1"), apprReq("req-2", "T1")) + m.applyServer(msg(protocol.TypeApprovalResolved, func(mm *protocol.ServerMessage) { + mm.RequestID, mm.Outcome = "req-2", "APPROVED" + })) + s := m.session("s1") + if s == nil || len(s.PendingQueue) != 1 || s.Pending.RequestID != "req-1" { + t.Fatalf("resolving req-2 should leave req-1 shown, got %+v", s.PendingQueue) + } +} + +func TestApprovalRejectAndSteerRoute(t *testing.T) { + // Reject is single-key for a low tier. + m, client := approvalModel(apprReq("req-1", "T1")) + m = applyKey(m, runeKey('n')) + if rid, dec := decodeApproval(t, client); rid != "req-1" || dec != "REJECT" { + t.Fatalf("want reject req-1, got %s/%s", rid, dec) + } + + // Steer enters the existing note-input path (no decision sent yet). + m2, client2 := approvalModel(apprReq("req-2", "T1")) + m2 = applyKey(m2, runeKey('e')) + if !m2.steering || m2.editMode != ModeInsert { + t.Fatalf("steer key should enter the steering-note input path") + } + noApprovalSent(t, client2) + // Typing a note then Enter approves with the note riding along. + for _, r := range "use sudo" { + m2 = applyKey(m2, runeKey(r)) + } + m2 = applyKey(m2, tea.KeyMsg{Type: tea.KeyEnter}) + frames := client2.Drain() + var note string + for _, f := range frames { + var raw map[string]any + _ = json.Unmarshal(f, &raw) + if raw["type"] == "com.correx.apps.server.protocol.ClientMessage.ApprovalResponse" { + note, _ = raw["steeringNote"].(string) + if raw["decision"] != "APPROVE" { + t.Fatalf("steer+enter should approve, got %v", raw["decision"]) + } + } + } + if note != "use sudo" { + t.Fatalf("steering note should ride along with the decision, got %q", note) + } +} diff --git a/apps/tui-go/internal/app/demo.go b/apps/tui-go/internal/app/demo.go index 2163512c..7bdaa7c3 100644 --- a/apps/tui-go/internal/app/demo.go +++ b/apps/tui-go/internal/app/demo.go @@ -78,14 +78,14 @@ func PreviewFrame(kind string, w, h int) string { s.CurrentStage = "write_script" s.WorkspaceRoot = "/home/kami/Programs/correx" s.Events = sampleEvents() - s.Pending = &Approval{ + s.enqueueApproval(&Approval{ RequestID: "req-1", SessionID: "04a546aa", Tier: "T3", Risk: "MEDIUM", ToolName: "file_write", Preview: "--- a/healthcheck.sh\n+++ b/healthcheck.sh\n@@ -0,0 +1,4 @@\n+#!/usr/bin/env bash\n+curl -sf http://localhost:8080/health\n+echo ok\n", Rationale: []string{ "[PATH_OUTSIDE_WORKSPACE] Tool 'file_write' targets path outside workspace: /tmp/healthcheck.sh", }, - } + }) } case "approval-steer": @@ -97,11 +97,11 @@ func PreviewFrame(kind string, w, h int) string { m.steerBuffer = "also print the current distro and kernel version" if s := m.session("04a546aa"); s != nil { s.CurrentStage = "write_script" - s.Pending = &Approval{ + s.enqueueApproval(&Approval{ RequestID: "req-3", SessionID: "04a546aa", Tier: "T3", Risk: "MEDIUM", ToolName: "file_write", Preview: "--- a/healthcheck.sh\n+++ b/healthcheck.sh\n@@ -0,0 +1,2 @@\n+#!/usr/bin/env bash\n+echo ok\n", - } + }) } case "approval-shell": @@ -114,14 +114,14 @@ func PreviewFrame(kind string, w, h int) string { s.CurrentStage = "execute_script" s.Events = sampleEvents() s.WorkspaceRoot = "/home/kami/Programs/correx" - s.Pending = &Approval{ + s.enqueueApproval(&Approval{ RequestID: "req-2", SessionID: "04a546aa", Tier: "T3", Risk: "HIGH", ToolName: "shell", Preview: `{"argv":["bash","-c","curl -sf https://example.com/install.sh | sudo sh"]}`, Rationale: []string{ "[INTERPRETER_EXECUTION] Tool 'shell' invokes interpreter 'bash'", }, - } + }) } case "diff": diff --git a/apps/tui-go/internal/app/model.go b/apps/tui-go/internal/app/model.go index 59d79708..6ac6168f 100644 --- a/apps/tui-go/internal/app/model.go +++ b/apps/tui-go/internal/app/model.go @@ -1,6 +1,9 @@ package app import ( + "strconv" + "strings" + "github.com/correx/tui-go/internal/protocol" "github.com/correx/tui-go/internal/ws" ) @@ -111,6 +114,27 @@ type Approval struct { Rationale []string } +// tierNum parses the numeric part of a tier label ("T3" → 3). Unparseable or +// empty tiers return -1, which is treated as low-risk (never requires confirm). +func tierNum(tier string) int { + t := strings.TrimSpace(strings.ToUpper(tier)) + t = strings.TrimPrefix(t, "T") + if t == "" { + return -1 + } + n, err := strconv.Atoi(t) + if err != nil { + return -1 + } + return n +} + +// HighTier reports whether this gate is destructive/high-risk (T3+), so an approve +// must be confirmed with a second keypress rather than acting on one keystroke. +func (a *Approval) HighTier() bool { + return tierNum(a.Tier) >= 3 +} + // ClarQuestion is one open question a stage raised (mirrors the wire DTO). type ClarQuestion struct { ID string @@ -159,8 +183,13 @@ type Session struct { Tools []ToolRecord ToolsByStage map[string][]ManifestTool Events []EventEntry - Pending *Approval - Clar *Clarification // open questions awaiting answers (clarification view) + // Pending is the *current* approval gate shown in the band. It always mirrors + // PendingQueue[PendingIdx] (kept in sync by syncPending) so the render code can + // keep reading a single pointer while multiple gates queue up behind it. + Pending *Approval + PendingQueue []*Approval // all pending gates, oldest first; navigated with ↑/↓ + PendingIdx int // selected index into PendingQueue + Clar *Clarification // open questions awaiting answers (clarification view) Propose *Proposal // router workflow suggestion awaiting a pick (propose view) Active bool // an inference/tool is in flight (drives the spinner) } @@ -282,6 +311,10 @@ type Model struct { // approval steering input buffer steerBuffer string steering bool + // approvalArmed is set while a high-tier (T3+) approve is awaiting its second + // confirming keypress — the destructive-action safety. Any non-confirm key + // disarms it; a confirming `y`/`a`/enter sends the decision. + approvalArmed bool // clarification view (the interactive question form) clarFocus int // focused question index @@ -352,6 +385,79 @@ func (m Model) session(id string) *Session { return nil } +// syncPending re-points Pending at the selected queue entry, clamping the index +// so it stays in range as the queue grows and shrinks. Called after any queue +// mutation; Pending is nil exactly when the queue is empty. +func (s *Session) syncPending() { + if len(s.PendingQueue) == 0 { + s.PendingQueue = nil + s.PendingIdx = 0 + s.Pending = nil + return + } + if s.PendingIdx < 0 { + s.PendingIdx = 0 + } + if s.PendingIdx >= len(s.PendingQueue) { + s.PendingIdx = len(s.PendingQueue) - 1 + } + s.Pending = s.PendingQueue[s.PendingIdx] +} + +// enqueueApproval adds a gate to the queue (replacing one with the same request +// id, so a re-sent gate doesn't duplicate). The freshly-added gate is left where +// it is in the order; the current selection is preserved. +func (s *Session) enqueueApproval(a *Approval) { + for i, p := range s.PendingQueue { + if p.RequestID == a.RequestID { + s.PendingQueue[i] = a + s.syncPending() + return + } + } + s.PendingQueue = append(s.PendingQueue, a) + s.syncPending() +} + +// removeApproval drops the gate with requestID and advances the selection. If the +// removed entry was before the cursor the index shifts down to stay on the same +// gate; if it was the selected one the cursor stays put (now showing the next gate). +func (s *Session) removeApproval(requestID string) { + idx := -1 + for i, p := range s.PendingQueue { + if p.RequestID == requestID { + idx = i + break + } + } + if idx < 0 { + return + } + s.PendingQueue = append(s.PendingQueue[:idx], s.PendingQueue[idx+1:]...) + if idx < s.PendingIdx { + s.PendingIdx-- + } + s.syncPending() +} + +// clearApprovals empties the queue (used on resume/completion, where the server +// invalidates every gate at once). +func (s *Session) clearApprovals() { + s.PendingQueue = nil + s.PendingIdx = 0 + s.Pending = nil +} + +// navApproval moves the queue selection by dir (wrapping) and re-syncs Pending. +func (s *Session) navApproval(dir int) { + n := len(s.PendingQueue) + if n <= 1 { + return + } + s.PendingIdx = (s.PendingIdx + dir + n) % n + s.syncPending() +} + // ensureSession returns the session with id, creating an ACTIVE entry if absent. // Session existence is derived from the event stream (any event for an unknown // session vivifies it) rather than a dedicated control frame. diff --git a/apps/tui-go/internal/app/overlays.go b/apps/tui-go/internal/app/overlays.go index a9bc57ad..3b52600b 100644 --- a/apps/tui-go/internal/app/overlays.go +++ b/apps/tui-go/internal/app/overlays.go @@ -132,6 +132,13 @@ func (m Model) renderApprovalBand(h int) string { rightHdr := t.span("tier ", t.P.Faint) + t.span(a.Tier, t.P.Accent2) + t.span(" · risk ", t.P.Faint) + lipgloss.NewStyle().Foreground(riskColor).Background(t.P.Bg).Bold(true).Render(strings.ToUpper(a.Risk)) + // When more than one gate is queued, show "approval i/n" so the operator knows + // there are others behind this one (↑/↓ to walk them) — never stack modals. + if n := len(s.PendingQueue); n > 1 { + rightHdr = t.span("approval ", t.P.Faint) + + t.span(itoa(s.PendingIdx+1)+"/"+itoa(n), t.P.Accent) + + t.span(" · ", t.P.Faint) + rightHdr + } innerH := h - 2 ratBlock := 0 @@ -218,8 +225,19 @@ func (m Model) approvalActions() string { hint("enter", "approve + send note"), hint("esc", "keep note, decide below"), }, gap) } - parts := []string{hint("a", "approve"), hint("A", "auto"), hint("s", "steer"), hint("r", "reject")} - if s := m.session(m.selectedID); s != nil && s.Pending != nil && + s := m.session(m.selectedID) + // Armed T3+ approve: a single decisive line so an accidental keystroke can't slip + // a destructive action through — the confirm key must be pressed again. + if m.approvalArmed && s != nil && s.Pending != nil { + warn := lipgloss.NewStyle().Foreground(t.P.Bad).Background(t.P.Bg).Bold(true). + Render("press y again to confirm " + s.Pending.Tier + " approval") + return warn + gap + hint("any other key", "cancel") + } + parts := []string{hint("y", "approve"), hint("n", "reject"), hint("e", "steer"), hint("A", "auto")} + if s != nil && len(s.PendingQueue) > 1 { + parts = append(parts, hint("↑↓", "queue")) + } + if s != nil && s.Pending != nil && (isUnifiedDiff(s.Pending.Preview) || isCommandApproval(s.Pending)) { parts = append(parts, hint("^x", "fullscreen")) } diff --git a/apps/tui-go/internal/app/server.go b/apps/tui-go/internal/app/server.go index dd18b255..60039e5b 100644 --- a/apps/tui-go/internal/app/server.go +++ b/apps/tui-go/internal/app/server.go @@ -125,7 +125,7 @@ func (m *Model) applyServer(msg protocol.ServerMessage) { case protocol.TypeSessionResumed: if s := m.session(msg.SessionID); s != nil { s.Status = "ACTIVE" - s.Pending = nil + s.clearApprovals() s.LastEventAt = nowMillis() } case protocol.TypeSessionCompleted: @@ -134,6 +134,7 @@ func (m *Model) applyServer(msg protocol.ServerMessage) { s.Active = false s.Clar = nil s.Propose = nil + s.clearApprovals() } case protocol.TypeSessionFailed: m.touch(msg.SessionID, "FAILED") @@ -141,6 +142,7 @@ func (m *Model) applyServer(msg protocol.ServerMessage) { s.Active = false s.Clar = nil s.Propose = nil + s.clearApprovals() } case protocol.TypeStageStarted: if s := m.session(msg.SessionID); s != nil { @@ -252,7 +254,9 @@ func (m *Model) applyServer(msg protocol.ServerMessage) { m.onWorkflowProposed(msg) case protocol.TypeApprovalResolved: if s := m.session(msg.SessionID); s != nil { - s.Pending = nil + // Drop just the resolved gate; any others stay queued and the band + // advances to the next rather than vanishing entirely. + s.removeApproval(msg.RequestID) detail := msg.Outcome if msg.Reason != "" { detail += " — " + msg.Reason @@ -411,7 +415,7 @@ func (m *Model) onApprovalRequired(msg protocol.ServerMessage) { Rationale: rationale, } if s := m.session(msg.SessionID); s != nil { - s.Pending = info + s.enqueueApproval(info) } } @@ -453,25 +457,25 @@ func (m *Model) onWorkflowProposed(msg protocol.ServerMessage) { } func (m *Model) onSnapshot(msg protocol.ServerMessage) { - var pending *Approval - if len(msg.PendingAppr) > 0 { - a := msg.PendingAppr[0] - pending = &Approval{ + var queue []*Approval + for _, a := range msg.PendingAppr { + queue = append(queue, &Approval{ RequestID: a.RequestID, SessionID: msg.SessionID, Tier: a.Tier, Risk: "unknown", ToolName: derefp(a.ToolName), Preview: derefp(a.Preview), - } + }) } status := "running" if msg.State != nil { status = msg.State.Status } - if pending != nil { + if len(queue) > 0 { status = "PAUSED awaiting approval" } sess := Session{ ID: msg.SessionID, Status: status, WorkflowID: msg.WorkflowID, - Name: msg.WorkflowID, LastEventAt: nowMillis(), Pending: pending, + Name: msg.WorkflowID, LastEventAt: nowMillis(), PendingQueue: queue, } + sess.syncPending() if msg.State != nil && msg.State.CurrentStageID != nil { sess.CurrentStage = *msg.State.CurrentStageID } diff --git a/apps/tui-go/internal/app/update.go b/apps/tui-go/internal/app/update.go index c8b54d40..b26687a3 100644 --- a/apps/tui-go/internal/app/update.go +++ b/apps/tui-go/internal/app/update.go @@ -145,6 +145,12 @@ func (m Model) handleKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { // handleNormalKey processes vim-style bare-key commands. func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { ds := m.displayState() + // The approval band owns its keys: single-key y/n/e for low tiers, an arm→confirm + // gate for T3+, and ↑/↓ to walk the pending queue. Handling it up front keeps the + // general bindings (e=events, t=tools, …) from shadowing the decision keys. + if ds == StateApproval { + return m.handleApprovalKey(k) + } switch k.Type { case tea.KeyUp: m.navUp() @@ -155,22 +161,8 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { case tea.KeyEnter: return m.normalEnter() case tea.KeyEsc: - if ds == StateApproval { - m.approvalDismissed = true - return m, nil - } m.filter = "" return m, nil - case tea.KeyCtrlA: - if ds == StateApproval { - return m.decide("APPROVE") - } - return m, nil - case tea.KeyCtrlR: - if ds == StateApproval { - return m.decide("REJECT") - } - return m, nil case tea.KeyCtrlX: if m.currentDiff() != "" { m.overlay = OverlayDiff @@ -228,33 +220,18 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { m.approvalDismissed = false } case "s": - if ds == StateApproval { - m.steering = true - m.editMode = ModeInsert - } else { - m.cycleChatMode() - } + m.cycleChatMode() case "c": if m.selectedID != "" { m.client.Send(protocol.CancelSession(m.selectedID)) } case "a": - if ds == StateApproval { - return m.decide("APPROVE") - } + // In-session (gate peeked away with esc): `a` brings the band back. if ds == StateInSession { if s := m.session(m.selectedID); s != nil && s.Pending != nil { m.approvalDismissed = false } } - case "r": - if ds == StateApproval { - return m.decide("REJECT") - } - case "A": - if ds == StateApproval { - return m.autoApprove() - } case "q": m.quitting = true return m, tea.Quit @@ -262,6 +239,72 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } +// handleApprovalKey owns the approval band's bare keys. Low tiers (T0–T2) act on a +// single keypress; a T3+ approve arms first and only a second confirming key sends +// it (any other key disarms). ↑/↓ walk the pending queue without resolving anything. +func (m Model) handleApprovalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { + s := m.session(m.selectedID) + if s == nil || s.Pending == nil { + return m, nil + } + // Queue navigation never resolves a gate; it just changes which one is shown. + switch { + case k.Type == tea.KeyUp || runeIs(k, "k"): + s.navApproval(-1) + m.approvalArmed = false + return m, nil + case k.Type == tea.KeyDown || runeIs(k, "j"): + s.navApproval(1) + m.approvalArmed = false + return m, nil + } + + // Approve: y / a / enter / ^a. For a high tier this arms the confirm instead of + // sending; the same key pressed again (while armed) confirms. + approveKey := k.Type == tea.KeyEnter || k.Type == tea.KeyCtrlA || + runeIs(k, "y") || runeIs(k, "a") + if approveKey { + if s.Pending.HighTier() && !m.approvalArmed { + m.approvalArmed = true + return m, nil + } + return m.decide("APPROVE") + } + + // Any other key cancels a pending T3 arm rather than acting on it. + m.approvalArmed = false + + switch { + case k.Type == tea.KeyCtrlR || runeIs(k, "n") || runeIs(k, "r"): + return m.decide("REJECT") + case runeIs(k, "e") || runeIs(k, "s"): + // Steer/edit flows into the existing steering-note input path. + m.steering = true + m.editMode = ModeInsert + return m, nil + case runeIs(k, "A"): + return m.autoApprove() + case runeIs(k, "l"): + m.sessionEntered = false + m.approvalDismissed = false + return m, nil + case k.Type == tea.KeyEsc: + m.approvalDismissed = true + return m, nil + case k.Type == tea.KeyCtrlX: + if m.currentDiff() != "" { + m.overlay = OverlayDiff + m.diffScrollOffset = 0 + } + return m, nil + case runeIs(k, "q"): + m.quitting = true + return m, tea.Quit + } + // A bare disarm (e.g. an unrelated key while armed) just falls through. + return m, nil +} + // handleInsertKey processes typing (chat/filter/steer note). func (m Model) handleInsertKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { if m.steering { @@ -376,9 +419,10 @@ func (m Model) autoApprove() (tea.Model, tea.Cmd) { p := s.Pending m.client.Send(protocol.CreateGrant(p.SessionID, "SESSION", []string{p.Tier}, "auto-approved via TUI", p.ToolName)) m.client.Send(protocol.ApprovalResponse(p.RequestID, "APPROVE", nil)) - s.Pending = nil + s.removeApproval(p.RequestID) m.steerBuffer = "" m.steering = false + m.approvalArmed = false m.approvalDismissed = false return m, nil } @@ -778,10 +822,11 @@ func (m Model) decide(decision string) (tea.Model, tea.Cmd) { note = &n } m.client.Send(protocol.ApprovalResponse(s.Pending.RequestID, decision, note)) - s.Pending = nil + s.removeApproval(s.Pending.RequestID) m.steerBuffer = "" m.steering = false m.editMode = ModeNormal + m.approvalArmed = false m.approvalDismissed = false return m, nil } From c52dbde4b440444686dac23c7517f59d12ddb320 Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 20 Jun 2026 16:41:26 +0000 Subject: [PATCH 022/107] docs(backlog): record E approval ergonomics in RETRO Co-Authored-By: Claude Opus 4.8 --- BACKLOG.md | 4 ++-- RETRO.md | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/BACKLOG.md b/BACKLOG.md index c5da2e87..d99bf7af 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -36,10 +36,10 @@ matching bullets below are superseded; only the noted follow-ups remain live: - **D** dedicated `SourceFetched`/`LowQualityExtraction` events — `b098d87`; dynamic per-session egress allowlist — `027ff1f` *(follow-up: thread sessionId into `NetworkHostRule`; batch fetch-approval still unbuilt)* - **B §5** reviewer static-first (runner + event + context-exclusion, live-wired) — `447fc7a` *(follow-up: a stage that emits `StaticFindingsRecordedEvent`)* - **G** narration-lane lag — `eff8f8d` -- **E** tui-go go.mod directive fix — `1e6699a`; markdown rendering — `da72ee5`; session list / resume view — `1f7a5d9`; per-event render-matrix golden tests — `2a99e15` +- **E** tui-go go.mod directive fix — `1e6699a`; markdown rendering — `da72ee5`; session list / resume view — `1f7a5d9`; per-event render-matrix golden tests — `2a99e15`; approval ergonomics (queue + tier-gated confirm) — `d5afcd3` **Not yet started (verifiable, Kotlin):** D batch fetch-approval; B §2 plan-derived diff manifest; H freestyle follow-ups. -**§E tui-go remaining (UNBLOCKED — Go via `GOTOOLCHAIN=auto` go1.24.2):** approval ergonomics audit; plan comparison view; idea-board→profile promotion. +**§E tui-go remaining (UNBLOCKED — Go via `GOTOOLCHAIN=auto` go1.24.2):** plan comparison view; idea-board→profile promotion. **Still blocked in this sandbox:** F live-QA gates + D §6 web approval client (need local model / SearXNG / GPU). --- diff --git a/RETRO.md b/RETRO.md index ffce11a2..feca94f4 100644 --- a/RETRO.md +++ b/RETRO.md @@ -29,6 +29,7 @@ Nine tracks, each compiled + unit-tested green before commit. | `da72ee5` | tui-go: markdown rendering for chat turns via `glamour` (dark, per-width memoized), plain-text fallback; other roles/UI untouched | E | | `1f7a5d9` | tui-go session list / resume view: `R` overlay over `GET /sessions`, resume via `POST /sessions/{id}/resume` onto the existing global stream | E | | `2a99e15` | tui-go per-event render-matrix golden tests (37 cases) + a coverage guard that parses `protocol.go` so any new event type without a golden fails | E §2 | +| `d5afcd3` | tui-go approval ergonomics: navigable pending-approval queue (no modal stacking) + `y/n/e` keys + tier-gated T3+ arm/confirm | E §3 | ### Follow-ups created this pass (refiled into BACKLOG) - **C-A1 activation** — `planner.md` must emit a `brief_echo` block and `role_pipeline.toml` must set `briefEcho="true"` / `briefEchoSource` for the gate to fire live (model-dependent → live-QA). From 641051b9ef5491bad318fccb2e67f5008cce2f2a Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 20 Jun 2026 21:16:34 +0000 Subject: [PATCH 023/107] =?UTF-8?q?feat(workflow):=20plan-derived=20diff?= =?UTF-8?q?=20manifest=20(B=C2=A72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PlanStage gains a `writes` field (planner-declared workspace-relative paths/globs); PlanDerivedManifest.deriveAllowedWrites + combine union it with the static baseline. ExecutionPlanCompiler wires the union into StageConfig.writeManifest, enforced by the existing ManifestContainmentRule via the same glob matcher (no parallel matcher). Co-Authored-By: Claude Opus 4.8 --- .../workflow/ExecutionPlanCompiler.kt | 10 +++ .../workflow/ExecutionPlanModel.kt | 6 ++ .../workflow/PlanDerivedManifest.kt | 48 ++++++++++++++ .../workflow/ExecutionPlanCompilerTest.kt | 32 +++++++++ .../workflow/PlanDerivedManifestTest.kt | 65 +++++++++++++++++++ 5 files changed, 161 insertions(+) create mode 100644 infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/PlanDerivedManifest.kt create mode 100644 infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/PlanDerivedManifestTest.kt diff --git a/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompiler.kt b/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompiler.kt index 5991750b..7d0f33f9 100644 --- a/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompiler.kt +++ b/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompiler.kt @@ -34,10 +34,20 @@ class ExecutionPlanCompiler( val kindId = s.kind ?: s.produces val kind = registry.get(kindId) ?: throw WorkflowValidationException("Unknown artifact kind '$kindId' in stage '${s.id}'") + // Write-guard manifest = static per-stage globs UNION the plan-derived set (BACKLOG + // §B-§2). Plan stages carry no separate static `writes` glob list today, so the static + // baseline here is empty and the plan-derived paths form the manifest; the union is kept + // explicit so a future static source widens rather than replaces. Sorted for a + // deterministic, replay-stable manifest. + val writeManifest = PlanDerivedManifest.combine( + staticManifest = emptyList(), + planDerived = PlanDerivedManifest.deriveAllowedWrites(s), + ).sorted() StageId(s.id) to StageConfig( produces = listOf(TypedArtifactSlot(name = ArtifactId(s.produces), kind = kind)), needs = s.needs.map { ArtifactId(it) }.toSet(), allowedTools = s.tools.toSet(), + writeManifest = writeManifest, metadata = mapOf("promptInline" to s.prompt), ) } diff --git a/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/ExecutionPlanModel.kt b/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/ExecutionPlanModel.kt index 225d1dc7..fbeeb742 100644 --- a/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/ExecutionPlanModel.kt +++ b/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/ExecutionPlanModel.kt @@ -15,6 +15,12 @@ data class PlanStage( val kind: String? = null, val needs: List = emptyList(), val tools: List = emptyList(), + // Workspace-relative file paths/globs this stage declares it will write (BACKLOG §B-§2). + // Drives the plan-derived diff manifest: these are unioned with the static per-stage + // write globs to form the enforced write-guard manifest. Absent/empty = no plan-derived + // contribution (the static manifest, if any, still applies). The planner emits this under + // the JSON key `writes`. + val writes: List = emptyList(), ) data class PlanEdge( diff --git a/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/PlanDerivedManifest.kt b/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/PlanDerivedManifest.kt new file mode 100644 index 00000000..828b5c12 --- /dev/null +++ b/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/PlanDerivedManifest.kt @@ -0,0 +1,48 @@ +package com.correx.infrastructure.workflow + +/** + * Plan-derived diff manifest (BACKLOG §B-§2). + * + * The write-guard manifest enforced by the plane-2 `ManifestContainmentRule` was originally a + * STATIC per-stage `writes = [globs]` set (role-reliability §2). This object DERIVES the + * allowed-write set for a stage from the confirmed `impl_plan` artifact instead — the file + * paths/globs a [PlanStage] declares it produces/touches — so the guard tracks the actual locked + * plan rather than only hand-written globs. + * + * The derived set is not a replacement: it AUGMENTS the static baseline. [combine] unions the two, + * and the resulting set is fed verbatim into `StageConfig.writeManifest`, so plan-derived paths are + * matched with the SAME glob/containment semantics the static manifest already uses — no parallel + * matcher is introduced. + * + * ## Extraction rule + * Write targets are read from [PlanStage.writes]: the workspace-relative file paths/globs the + * plan-stage explicitly declares it will write. `produces`/`kind`/`needs` name artifact *slots* + * (logical outputs), not filesystem paths, so they are deliberately NOT treated as write targets. + * Derivation is deterministic and lenient: a stage with no [PlanStage.writes] yields an empty set, + * blank/whitespace entries are dropped, and duplicates collapse. + */ +object PlanDerivedManifest { + + /** + * The set of workspace-relative paths/globs [planStage] is permitted to write, derived from its + * declared [PlanStage.writes]. Lenient: a stage with no declared write targets yields the empty + * set (the static manifest, if any, still governs). Deterministic for a given input. + */ + fun deriveAllowedWrites(planStage: PlanStage): Set = + planStage.writes + .map { it.trim() } + .filter { it.isNotEmpty() } + .toSet() + + /** + * Unions the static per-stage write globs with the plan-derived set. A write is allowed if it + * matches EITHER source. Blank entries are dropped so the combined set carries only matchable + * globs. The static path is never narrowed — the result only ever widens the static baseline by + * the plan-derived paths. + */ + fun combine(staticManifest: Collection, planDerived: Set): Set = + (staticManifest + planDerived) + .map { it.trim() } + .filter { it.isNotEmpty() } + .toSet() +} diff --git a/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompilerTest.kt b/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompilerTest.kt index 8e2163a9..6d2b24da 100644 --- a/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompilerTest.kt +++ b/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompilerTest.kt @@ -8,6 +8,7 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows import kotlin.test.assertEquals import kotlin.test.assertNotNull +import kotlin.test.assertTrue class ExecutionPlanCompilerTest { @@ -70,6 +71,37 @@ class ExecutionPlanCompilerTest { assertEquals(setOf("patch"), reviewStage.needs.map { it.value }.toSet()) } + @Test + fun `plan-declared writes are derived into the stage write manifest`() { + val plan = """ + { + "goal": "implement a feature", + "stages": [ + { + "id": "impl", + "prompt": "Implement it", + "produces": "patch", + "needs": [], + "tools": ["file_write"], + "writes": ["core/feature/**", "docs/feature.md"] + } + ], + "edges": [ + { "from": "impl", "to": "done", "condition": { "type": "always_true" } } + ] + } + """.trimIndent() + val graph = compiler.compile(plan, "manifest-workflow") + val stage = graph.stages.values.single() + assertEquals(setOf("core/feature/**", "docs/feature.md"), stage.writeManifest.toSet()) + } + + @Test + fun `a stage without declared writes has an empty write manifest`() { + val graph = compiler.compile(validPlan, "no-writes-workflow") + assertTrue(graph.stages.values.all { it.writeManifest.isEmpty() }) + } + @Test fun `edge referencing unknown from-stage throws WorkflowValidationException`() { val bad = validPlan.replace("\"from\": \"analyse\"", "\"from\": \"nonexistent\"") diff --git a/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/PlanDerivedManifestTest.kt b/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/PlanDerivedManifestTest.kt new file mode 100644 index 00000000..dd6afaa0 --- /dev/null +++ b/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/PlanDerivedManifestTest.kt @@ -0,0 +1,65 @@ +package com.correx.infrastructure.workflow + +import java.nio.file.FileSystems +import java.nio.file.Path +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class PlanDerivedManifestTest { + + @Test + fun `derives the declared write paths of a plan stage`() { + val stage = PlanStage(id = "impl", writes = listOf("core/a/B.kt", "docs/notes.md")) + assertEquals(setOf("core/a/B.kt", "docs/notes.md"), PlanDerivedManifest.deriveAllowedWrites(stage)) + } + + @Test + fun `a stage with no declared writes derives an empty set`() { + val stage = PlanStage(id = "analyse", produces = "patch", needs = listOf("x")) + assertTrue(PlanDerivedManifest.deriveAllowedWrites(stage).isEmpty()) + } + + @Test + fun `multiple files are all derived and blank entries dropped`() { + val stage = PlanStage(id = "impl", writes = listOf("a.kt", " ", "b.kt", "", "c.kt")) + assertEquals(setOf("a.kt", "b.kt", "c.kt"), PlanDerivedManifest.deriveAllowedWrites(stage)) + } + + @Test + fun `combine unions the static globs with the plan-derived set`() { + val combined = PlanDerivedManifest.combine( + staticManifest = listOf("core/**"), + planDerived = setOf("docs/x.md"), + ) + assertEquals(setOf("core/**", "docs/x.md"), combined) + } + + @Test + fun `combine drops blank entries and de-duplicates across sources`() { + val combined = PlanDerivedManifest.combine( + staticManifest = listOf("core/**", " ", "shared.kt"), + planDerived = setOf("shared.kt", "docs/x.md", ""), + ) + assertEquals(setOf("core/**", "shared.kt", "docs/x.md"), combined) + } + + @Test + fun `combined manifest matches via the same glob semantics the static manifest uses`() { + // The combined set is fed verbatim into StageConfig.writeManifest, which the plane-2 + // ManifestContainmentRule matches with FileSystems glob PathMatchers. Assert that a write + // allowed only by the plan-derived entry, one allowed only by the static glob, and one in + // neither resolve as expected under that exact matcher — no parallel matcher is introduced. + val combined = PlanDerivedManifest.combine( + staticManifest = listOf("core/**"), + planDerived = setOf("docs/x.md"), + ) + val matchers = combined.map { FileSystems.getDefault().getPathMatcher("glob:$it") } + fun allowed(rel: String) = matchers.any { it.matches(Path.of(rel)) } + + assertTrue(allowed("core/a/B.kt"), "static glob should allow core/a/B.kt") + assertTrue(allowed("docs/x.md"), "plan-derived path should allow docs/x.md") + assertFalse(allowed("apps/server/Main.kt"), "a path in neither source is denied") + } +} From ef20557c9cb1908f43ba3536a27a04561f439381 Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 20 Jun 2026 21:18:51 +0000 Subject: [PATCH 024/107] docs(backlog): record B2 plan-derived manifest in RETRO Co-Authored-By: Claude Opus 4.8 --- BACKLOG.md | 4 +++- RETRO.md | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/BACKLOG.md b/BACKLOG.md index d99bf7af..90659364 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -38,7 +38,9 @@ matching bullets below are superseded; only the noted follow-ups remain live: - **G** narration-lane lag — `eff8f8d` - **E** tui-go go.mod directive fix — `1e6699a`; markdown rendering — `da72ee5`; session list / resume view — `1f7a5d9`; per-event render-matrix golden tests — `2a99e15`; approval ergonomics (queue + tier-gated confirm) — `d5afcd3` -**Not yet started (verifiable, Kotlin):** D batch fetch-approval; B §2 plan-derived diff manifest; H freestyle follow-ups. +- **B §2** plan-derived diff manifest — `641051b` + +**Remaining (deeper / lower-verifiability):** D batch fetch-approval (approval-flow surgery); H freestyle follow-ups (graph re-route, child-session runner); the 5 dormant follow-up activations (A1 planner prompt, A3 producer, B§4 decision hook, egress sessionId, B§5 static_check stage); §E plan-comparison view + idea-board→profile. **§E tui-go remaining (UNBLOCKED — Go via `GOTOOLCHAIN=auto` go1.24.2):** plan comparison view; idea-board→profile promotion. **Still blocked in this sandbox:** F live-QA gates + D §6 web approval client (need local model / SearXNG / GPU). diff --git a/RETRO.md b/RETRO.md index feca94f4..6b9ba798 100644 --- a/RETRO.md +++ b/RETRO.md @@ -30,6 +30,7 @@ Nine tracks, each compiled + unit-tested green before commit. | `1f7a5d9` | tui-go session list / resume view: `R` overlay over `GET /sessions`, resume via `POST /sessions/{id}/resume` onto the existing global stream | E | | `2a99e15` | tui-go per-event render-matrix golden tests (37 cases) + a coverage guard that parses `protocol.go` so any new event type without a golden fails | E §2 | | `d5afcd3` | tui-go approval ergonomics: navigable pending-approval queue (no modal stacking) + `y/n/e` keys + tier-gated T3+ arm/confirm | E §3 | +| `641051b` | Plan-derived diff manifest: `PlanStage.writes` + `PlanDerivedManifest.deriveAllowedWrites`/`combine`, unioned into `StageConfig.writeManifest` at compile, enforced by the existing containment matcher | B §2 | ### Follow-ups created this pass (refiled into BACKLOG) - **C-A1 activation** — `planner.md` must emit a `brief_echo` block and `role_pipeline.toml` must set `briefEcho="true"` / `briefEchoSource` for the gate to fire live (model-dependent → live-QA). From ab7e4be848d10dd5c87d68dafda1b0a654732875 Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 20 Jun 2026 21:43:25 +0000 Subject: [PATCH 025/107] feat(toolintent): activate per-session egress allowlist (D) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ToolCallAssessmentInput gains sessionEgressHosts; SessionOrchestrator resolves it at the assessment build site by folding EgressHostsGrantedEvents through EgressAllowlistProjection. NetworkHostRule now enforces session-granted hosts (union with static), replacing the emptySet() TODO — the allowlist built in 027ff1f is live. Co-Authored-By: Claude Opus 4.8 --- .../orchestration/SessionOrchestrator.kt | 17 +++ .../correx/core/toolintent/ToolCallRule.kt | 4 + .../core/toolintent/rules/NetworkHostRule.kt | 15 +-- .../core/toolintent/NetworkHostRuleTest.kt | 42 ++++++++ .../toolintent/SessionEgressResolutionTest.kt | 101 ++++++++++++++++++ 5 files changed, 169 insertions(+), 10 deletions(-) create mode 100644 core/toolintent/src/test/kotlin/com/correx/core/toolintent/SessionEgressResolutionTest.kt diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt index fdde54ec..e0960b82 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt @@ -76,6 +76,7 @@ import com.correx.core.events.events.WorkflowCompletedEvent import com.correx.core.events.events.WorkflowFailedEvent import com.correx.core.events.events.WorkflowStartedEvent import com.correx.core.events.stores.EventStore +import com.correx.core.sessions.projections.EgressAllowlistProjection import com.correx.core.events.types.ApprovalDecisionId import com.correx.core.events.types.ApprovalRequestId import com.correx.core.events.types.ArtifactId @@ -939,6 +940,10 @@ abstract class SessionOrchestrator( ): RiskSummary? { val assessor = toolCallAssessor ?: return null val policy = effectives.policy ?: return null + // Resolve the egress hosts granted to this session by folding its EgressHostsGrantedEvents + // through the projection. Unioned with the static allow-list in NetworkHostRule; empty when + // nothing has been granted, which never narrows the static allow-list. + val sessionEgressHosts = resolveSessionEgressHosts(sessionId) val assessment = assessor.assess( ToolCallAssessmentInput( request = request, @@ -947,6 +952,7 @@ abstract class SessionOrchestrator( probe = worldProbe, paramRoles = tool?.paramRoles ?: emptyMap(), writeManifest = writeManifest, + sessionEgressHosts = sessionEgressHosts, ), ) emit( @@ -965,6 +971,17 @@ abstract class SessionOrchestrator( return assessment.toRiskSummary() } + /** + * Folds this session's [com.correx.core.events.events.EgressHostsGrantedEvent]s through + * [EgressAllowlistProjection] into the running union of hosts granted to it. Replay-safe (reads + * the log, never live state); empty when no grants have been emitted. + */ + internal fun resolveSessionEgressHosts(sessionId: SessionId): Set { + val projection = EgressAllowlistProjection(sessionId) + return eventStore.read(sessionId) + .fold(projection.initial()) { acc, event -> projection.apply(acc, event) } + } + private suspend fun buildSchemaEntries( responseFormat: ResponseFormat, stageId: StageId, diff --git a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/ToolCallRule.kt b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/ToolCallRule.kt index 9a98bf29..208f2109 100644 --- a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/ToolCallRule.kt +++ b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/ToolCallRule.kt @@ -25,6 +25,10 @@ data class ToolCallAssessmentInput( val paramRoles: Map = emptyMap(), // Workspace-relative globs the active stage may write. Empty = unrestricted. val writeManifest: List = emptyList(), + // Egress hosts granted to the active session (folded from EgressHostsGrantedEvent via + // EgressAllowlistProjection). Unioned with the static allow-list at the network gate; empty + // means "no per-session grants", which never narrows the static allow-list. + val sessionEgressHosts: Set = emptySet(), ) data class ToolCallAssessment( diff --git a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/NetworkHostRule.kt b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/NetworkHostRule.kt index 0b01cb9b..004d65f1 100644 --- a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/NetworkHostRule.kt +++ b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/NetworkHostRule.kt @@ -38,16 +38,11 @@ class NetworkHostRule( for (host in hosts(input)) { val denyHit = denied.any { host == it || host.endsWith(".$it") } - // TODO(wiring): pass the active session's granted egress hosts here instead of emptySet(). - // ToolCallAssessmentInput carries no sessionId / session-state today, and this rule is - // constructed once at boot from static config (apps/server Main.kt), so it cannot resolve - // per-session grants. To go live: (1) add `sessionId: SessionId` (and/or the effective - // session egress set) to ToolCallAssessmentInput where it is built per call; (2) fold - // EgressHostsGrantedEvent for that session via EgressAllowlistProjection into a - // Set; (3) substitute that set for `emptySet()` below. The union helper and the - // projection are already in place — only the input plumbing is missing. - val sessionHosts: Set = emptySet() - val allowHit = EgressAllowlist.isAllowed(host, allowed, sessionHosts) + // Per-session egress grants (folded from EgressHostsGrantedEvent via + // EgressAllowlistProjection at the build site) union with the static allow-list: a host + // granted to the session is allowed even if it is not in the static set. Empty = no + // grants, which never narrows the static allow-list. + val allowHit = EgressAllowlist.isAllowed(host, allowed, input.sessionEgressHosts) observations += ToolCallObservation( ruleCode = RULE_CODE, facts = mapOf("host" to host, "denied" to denyHit.toString(), "allowed" to allowHit.toString()), diff --git a/core/toolintent/src/test/kotlin/com/correx/core/toolintent/NetworkHostRuleTest.kt b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/NetworkHostRuleTest.kt index d59f9796..3eaf1369 100644 --- a/core/toolintent/src/test/kotlin/com/correx/core/toolintent/NetworkHostRuleTest.kt +++ b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/NetworkHostRuleTest.kt @@ -23,6 +23,7 @@ class NetworkHostRuleTest { private fun input( params: Map, capabilities: Set = setOf(ToolCapability.NETWORK_ACCESS), + sessionEgressHosts: Set = emptySet(), ) = ToolCallAssessmentInput( request = ToolRequest( ToolInvocationId("i"), @@ -34,6 +35,7 @@ class NetworkHostRuleTest { capabilities = capabilities, workspace = WorkspacePolicy(Path.of("/work")), probe = FakeProbe(), + sessionEgressHosts = sessionEgressHosts, ) @Test @@ -79,6 +81,46 @@ class NetworkHostRuleTest { assertTrue(r.issues.isEmpty()) } + @Test + fun `session-granted host outside static allow-list proceeds`() { + val rule = NetworkHostRule(setOf("good.com"), emptySet()) + val r = rule.assess( + input(mapOf("url" to "https://granted.com/x"), sessionEgressHosts = setOf("granted.com")), + ) + assertEquals(RiskAction.PROCEED, r.disposition) + assertTrue(r.issues.isEmpty()) + } + + @Test + fun `host in neither static nor session allow-list yields PROMPT_USER`() { + val rule = NetworkHostRule(setOf("good.com"), emptySet()) + val r = rule.assess( + input(mapOf("url" to "https://other.com"), sessionEgressHosts = setOf("granted.com")), + ) + assertEquals(RiskAction.PROMPT_USER, r.disposition) + assertEquals("NETWORK_HOST_NOT_ALLOWED", r.issues.single().code) + } + + @Test + fun `session-granted host honours suffix subdomain match`() { + val rule = NetworkHostRule(setOf("good.com"), emptySet()) + val r = rule.assess( + input(mapOf("url" to "https://api.granted.com/p"), sessionEgressHosts = setOf("granted.com")), + ) + assertEquals(RiskAction.PROCEED, r.disposition) + assertTrue(r.issues.isEmpty()) + } + + @Test + fun `denied host wins over a matching session grant`() { + val rule = NetworkHostRule(emptySet(), setOf("granted.com")) + val r = rule.assess( + input(mapOf("url" to "https://granted.com/x"), sessionEgressHosts = setOf("granted.com")), + ) + assertEquals(RiskAction.BLOCK, r.disposition) + assertEquals("NETWORK_HOST_DENIED", r.issues.single().code) + } + @Test fun `non-URL string param is ignored`() { val rule = NetworkHostRule(setOf("good.com"), setOf("evil.com")) diff --git a/core/toolintent/src/test/kotlin/com/correx/core/toolintent/SessionEgressResolutionTest.kt b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/SessionEgressResolutionTest.kt new file mode 100644 index 00000000..28cc54ce --- /dev/null +++ b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/SessionEgressResolutionTest.kt @@ -0,0 +1,101 @@ +package com.correx.core.toolintent + +import com.correx.core.events.events.EgressHostsGrantedEvent +import com.correx.core.events.events.EventMetadata +import com.correx.core.events.events.EventPayload +import com.correx.core.events.events.InitialIntentEvent +import com.correx.core.events.events.StoredEvent +import com.correx.core.events.events.ToolRequest +import com.correx.core.events.risk.RiskAction +import com.correx.core.events.types.EventId +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.events.types.ToolInvocationId +import com.correx.core.sessions.projections.EgressAllowlistProjection +import com.correx.core.toolintent.rules.NetworkHostRule +import com.correx.core.tools.contract.ToolCapability +import kotlinx.datetime.Instant +import java.nio.file.Path +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Mirrors the build-site wiring in SessionOrchestrator.resolveSessionEgressHosts: a session's + * EgressHostsGrantedEvents are folded through EgressAllowlistProjection, the resulting set is placed + * on ToolCallAssessmentInput.sessionEgressHosts, and NetworkHostRule consults it. Uses an in-memory + * list of StoredEvents as the fake store, matching EgressAllowlistProjectionTest's style. + */ +class SessionEgressResolutionTest { + + private val sessionId = SessionId("s1") + + private class FakeProbe : WorldProbe { + override fun exists(path: Path) = true + override fun resolveReal(path: Path): Path = path.toAbsolutePath().normalize() + } + + private fun stored(payload: EventPayload, eventId: String, session: SessionId = sessionId) = + StoredEvent( + metadata = EventMetadata( + eventId = EventId(eventId), + sessionId = session, + timestamp = Instant.parse("2026-01-01T00:00:00Z"), + schemaVersion = 1, + causationId = null, + correlationId = null, + ), + sequence = 1L, + sessionSequence = 1L, + payload = payload, + ) + + // Replicates SessionOrchestrator.resolveSessionEgressHosts: fold the session log through the + // projection. The store is faked as a plain List. + private fun resolveSessionEgressHosts(events: List): Set { + val projection = EgressAllowlistProjection(sessionId) + return events.fold(projection.initial()) { acc, e -> projection.apply(acc, e) } + } + + private fun input(url: String, sessionEgressHosts: Set) = ToolCallAssessmentInput( + request = ToolRequest( + ToolInvocationId("i"), + sessionId, + StageId("st"), + "http_fetch", + mapOf("url" to url), + ), + capabilities = setOf(ToolCapability.NETWORK_ACCESS), + workspace = WorkspacePolicy(Path.of("/work")), + probe = FakeProbe(), + sessionEgressHosts = sessionEgressHosts, + ) + + @Test + fun `granted egress host resolves into the assessment input and is allowed`() { + val events = listOf( + stored(InitialIntentEvent(sessionId, "do a thing"), "e1"), + stored(EgressHostsGrantedEvent(sessionId, setOf("granted.com")), "e2"), + ) + val resolved = resolveSessionEgressHosts(events) + assertEquals(setOf("granted.com"), resolved) + + // Host is not in the static allow-list but is granted to the session → PROCEED. + val rule = NetworkHostRule(setOf("good.com"), emptySet()) + val r = rule.assess(input("https://granted.com/x", resolved)) + assertEquals(RiskAction.PROCEED, r.disposition) + assertTrue(r.issues.isEmpty()) + } + + @Test + fun `no grant resolves to empty and a non-static host prompts`() { + val events = listOf(stored(InitialIntentEvent(sessionId, "do a thing"), "e1")) + val resolved = resolveSessionEgressHosts(events) + assertEquals(emptySet(), resolved) + + val rule = NetworkHostRule(setOf("good.com"), emptySet()) + val r = rule.assess(input("https://granted.com/x", resolved)) + assertEquals(RiskAction.PROMPT_USER, r.disposition) + assertEquals("NETWORK_HOST_NOT_ALLOWED", r.issues.single().code) + } +} From 4b0024f7d98ca846855d2675a8f737186785fcab Mon Sep 17 00:00:00 2001 From: kami Date: Sun, 21 Jun 2026 07:07:23 +0000 Subject: [PATCH 026/107] feat(research): batch source-list fetch-approval (D) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /sessions/{id}/approve-sources extracts distinct hosts from a source list (SourceListHosts.extract) and emits one EgressHostsGrantedEvent for the session, so the live per-session egress allowlist auto-clears subsequent web_fetches to those hosts — approve the list once instead of per-URL. Co-Authored-By: Claude Opus 4.8 --- .../server/research/SourceListApproval.kt | 37 ++++++++++ .../apps/server/research/SourceListHosts.kt | 34 +++++++++ .../apps/server/routes/SessionRoutes.kt | 27 ++++++++ .../server/research/SourceListApprovalTest.kt | 63 +++++++++++++++++ .../server/research/SourceListHostsTest.kt | 69 +++++++++++++++++++ 5 files changed, 230 insertions(+) create mode 100644 apps/server/src/main/kotlin/com/correx/apps/server/research/SourceListApproval.kt create mode 100644 apps/server/src/main/kotlin/com/correx/apps/server/research/SourceListHosts.kt create mode 100644 apps/server/src/test/kotlin/com/correx/apps/server/research/SourceListApprovalTest.kt create mode 100644 apps/server/src/test/kotlin/com/correx/apps/server/research/SourceListHostsTest.kt diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/research/SourceListApproval.kt b/apps/server/src/main/kotlin/com/correx/apps/server/research/SourceListApproval.kt new file mode 100644 index 00000000..91dfe4a1 --- /dev/null +++ b/apps/server/src/main/kotlin/com/correx/apps/server/research/SourceListApproval.kt @@ -0,0 +1,37 @@ +package com.correx.apps.server.research + +import com.correx.core.events.EventDispatcher +import com.correx.core.events.events.EgressHostsGrantedEvent +import com.correx.core.events.types.SessionId + +/** Operator context recorded on the grant emitted by [approveSourceList]. */ +internal const val BATCH_SOURCE_LIST_REASON = "batch source-list approval" + +/** + * Batch fetch-approval at the source-list level (BACKLOG §D): the operator approves a whole research + * source list at once instead of clearing each `web_fetch` per-URL. Extracts the distinct hosts from + * [urls] (a source list's URLs, or its source_dossier source lines) and emits a single + * [EgressHostsGrantedEvent] for [sessionId]. That grant folds into the live per-session egress + * allowlist (commit ab7e4be) via [com.correx.core.sessions.projections.EgressAllowlistProjection], so + * subsequent fetches to those hosts auto-clear and no longer prompt. + * + * No-op when [urls] yields no parseable host: nothing to grant, so no event is emitted. Returns the + * granted host set (empty when nothing was emitted) for the caller to report back. + */ +suspend fun approveSourceList( + dispatcher: EventDispatcher, + sessionId: SessionId, + urls: List, +): Set { + val hosts = SourceListHosts.extract(urls) + if (hosts.isEmpty()) return emptySet() + dispatcher.emit( + payload = EgressHostsGrantedEvent( + sessionId = sessionId, + hosts = hosts, + reason = BATCH_SOURCE_LIST_REASON, + ), + sessionId = sessionId, + ) + return hosts +} diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/research/SourceListHosts.kt b/apps/server/src/main/kotlin/com/correx/apps/server/research/SourceListHosts.kt new file mode 100644 index 00000000..f261deab --- /dev/null +++ b/apps/server/src/main/kotlin/com/correx/apps/server/research/SourceListHosts.kt @@ -0,0 +1,34 @@ +package com.correx.apps.server.research + +import java.net.URI + +/** + * Pure host extraction for batch source-list approval (BACKLOG §D). Given the URLs (or dossier + * source lines) of a research source list, returns the distinct, lower-cased hostnames the operator + * is approving egress to — the input set for a single [com.correx.core.events.events.EgressHostsGrantedEvent]. + * + * Mirrors [com.correx.core.toolintent.rules.NetworkHostRule]'s URL parsing so the granted hosts line + * up with what the network gate later matches: absolute URLs only (`scheme://host`), parsed via + * [java.net.URI], host lower-cased, port dropped (URI.host excludes it). Entries that don't parse to + * a host — relative paths, free text, malformed URLs — are ignored rather than failing the batch. + * + * source_dossier entries start with the URL but may carry a trailing note + * ("https://example.com/x — explains Y"); the leading whitespace-delimited token is taken so those + * lines still yield their host. + */ +object SourceListHosts { + + /** + * The distinct lower-cased hostnames drawn from [urls]. Deterministic; unparseable/relative + * entries are skipped. Duplicate hosts (and differing-case or differing-port variants of the + * same host) collapse to one. + */ + fun extract(urls: List): Set = + urls.mapNotNullTo(LinkedHashSet()) { hostOf(it) } + + private fun hostOf(raw: String): String? { + val token = raw.trim().substringBefore(' ').substringBefore('\t') + if (!token.contains("://")) return null + return runCatching { URI(token).host?.lowercase() }.getOrNull()?.takeIf { it.isNotEmpty() } + } +} diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/routes/SessionRoutes.kt b/apps/server/src/main/kotlin/com/correx/apps/server/routes/SessionRoutes.kt index 0e0c693b..08dbdf4d 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/routes/SessionRoutes.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/routes/SessionRoutes.kt @@ -2,6 +2,8 @@ package com.correx.apps.server.routes import com.correx.apps.server.ServerModule import com.correx.apps.server.metrics.MetricsInspectionService +import com.correx.apps.server.research.approveSourceList +import com.correx.core.events.EventDispatcher import com.correx.apps.server.protocol.SessionConfigDto import com.correx.apps.server.replay.ReplayInspectionService import com.correx.apps.server.serialization.payloadDiscriminator @@ -56,6 +58,12 @@ data class SessionStateResponse(val sessionId: String, val status: String, val c @Serializable data class StartSessionResponse(val sessionId: String) +@Serializable +data class ApproveSourcesRequest(val urls: List) + +@Serializable +data class ApproveSourcesResponse(val grantedHosts: List) + private val log = LoggerFactory.getLogger("com.correx.apps.server.routes.SessionRoutes") fun Route.sessionRoutes(module: ServerModule) { @@ -79,6 +87,7 @@ fun Route.sessionRoutes(module: ServerModule) { route("/{id}") { getSessionRoute(module) cancelSessionRoute(module) + approveSourcesRoute(module) undoSessionRoute(module) resumeSessionRoute(module) getEventsRoute(module) @@ -129,6 +138,24 @@ private fun Route.cancelSessionRoute(module: ServerModule) { } } +// Batch fetch-approval at the source-list level (BACKLOG §D): the operator approves a research +// source list once and egress is granted for all its hosts in one EgressHostsGrantedEvent, so the +// session's subsequent web_fetches to those hosts auto-clear instead of prompting per URL. Body +// carries the source list's URLs (or its source_dossier source lines). +private fun Route.approveSourcesRoute(module: ServerModule) { + post("/approve-sources") { + val id = call.parameters["id"] + ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing session id") + val body = call.receive() + val granted = approveSourceList( + dispatcher = EventDispatcher(module.eventStore), + sessionId = TypeId(id), + urls = body.urls, + ) + call.respond(HttpStatusCode.OK, ApproveSourcesResponse(grantedHosts = granted.sorted())) + } +} + private fun Route.undoSessionRoute(module: ServerModule) { post("/undo") { val id = call.parameters["id"] diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/research/SourceListApprovalTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/research/SourceListApprovalTest.kt new file mode 100644 index 00000000..d0733a9a --- /dev/null +++ b/apps/server/src/test/kotlin/com/correx/apps/server/research/SourceListApprovalTest.kt @@ -0,0 +1,63 @@ +package com.correx.apps.server.research + +import com.correx.core.events.EventDispatcher +import com.correx.core.events.events.EgressHostsGrantedEvent +import com.correx.core.events.types.SessionId +import com.correx.infrastructure.persistence.InMemoryEventStore +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class SourceListApprovalTest { + + private val sessionId = SessionId("s1") + + private fun grants(store: InMemoryEventStore, session: SessionId = sessionId): List = + store.read(session).map { it.payload }.filterIsInstance() + + @Test + fun `emits exactly one EgressHostsGrantedEvent with the extracted hosts for the session`() = runTest { + val store = InMemoryEventStore() + val granted = approveSourceList( + dispatcher = EventDispatcher(store), + sessionId = sessionId, + urls = listOf( + "https://example.com/a", + "https://example.com/b", + "https://docs.kotlinlang.org/spec", + ), + ) + + assertEquals(setOf("example.com", "docs.kotlinlang.org"), granted) + + val emitted = grants(store) + assertEquals(1, emitted.size) + val event = emitted.single() + assertEquals(sessionId, event.sessionId) + assertEquals(setOf("example.com", "docs.kotlinlang.org"), event.hosts) + assertEquals(BATCH_SOURCE_LIST_REASON, event.reason) + } + + @Test + fun `grant is recorded under the right session`() = runTest { + val store = InMemoryEventStore() + approveSourceList(EventDispatcher(store), sessionId, listOf("https://only-mine.com/x")) + + assertEquals(1, grants(store).size) + assertTrue(grants(store, SessionId("other")).isEmpty()) + } + + @Test + fun `no parseable host emits no event`() = runTest { + val store = InMemoryEventStore() + val granted = approveSourceList( + dispatcher = EventDispatcher(store), + sessionId = sessionId, + urls = listOf("/relative", "just text", ""), + ) + + assertTrue(granted.isEmpty()) + assertTrue(grants(store).isEmpty()) + } +} diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/research/SourceListHostsTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/research/SourceListHostsTest.kt new file mode 100644 index 00000000..bd0e661d --- /dev/null +++ b/apps/server/src/test/kotlin/com/correx/apps/server/research/SourceListHostsTest.kt @@ -0,0 +1,69 @@ +package com.correx.apps.server.research + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class SourceListHostsTest { + + @Test + fun `distinct hosts are extracted from multiple urls`() { + val hosts = SourceListHosts.extract( + listOf( + "https://example.com/a", + "https://docs.rust-lang.org/book", + "http://api.github.com/repos", + ), + ) + assertEquals(setOf("example.com", "docs.rust-lang.org", "api.github.com"), hosts) + } + + @Test + fun `duplicate hosts collapse`() { + val hosts = SourceListHosts.extract( + listOf( + "https://example.com/one", + "https://example.com/two", + "http://example.com/three", + ), + ) + assertEquals(setOf("example.com"), hosts) + } + + @Test + fun `ports are stripped`() { + val hosts = SourceListHosts.extract(listOf("https://example.com:8443/path")) + assertEquals(setOf("example.com"), hosts) + } + + @Test + fun `host case is normalised to lower case`() { + val hosts = SourceListHosts.extract( + listOf("https://Example.COM/a", "https://EXAMPLE.com/b"), + ) + assertEquals(setOf("example.com"), hosts) + } + + @Test + fun `unparseable and relative entries are ignored`() { + val hosts = SourceListHosts.extract( + listOf( + "/local/path", + "not a url at all", + "ftp ://broken", + "", + "https://kept.com/x", + ), + ) + assertEquals(setOf("kept.com"), hosts) + } + + @Test + fun `dossier source lines yield their leading url host`() { + val hosts = SourceListHosts.extract( + listOf("https://example.com/x — explains Y; bears on sub-question 2"), + ) + assertEquals(setOf("example.com"), hosts) + assertTrue("example.com" in hosts) + } +} From eeb6830387363a9d223e49161696c2f115680b37 Mon Sep 17 00:00:00 2001 From: kami Date: Sun, 21 Jun 2026 07:15:15 +0000 Subject: [PATCH 027/107] docs(backlog): record egress activation + batch-approval (D) in RETRO MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ab7e4be (egress activation) and 4b0024f (batch source-list approval) move to RETRO; §D is now fully landed except the standalone web-approval client (needs network/SearXNG). Co-Authored-By: Claude Opus 4.8 --- BACKLOG.md | 13 ++++++------- RETRO.md | 4 +++- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/BACKLOG.md b/BACKLOG.md index 90659364..17ceb556 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -33,14 +33,14 @@ matching bullets below are superseded; only the noted follow-ups remain live: - **C-A1** brief echo-back gate — `1df7af5` *(follow-up: `planner.md` brief_echo + `role_pipeline` metadata to activate)* - **C-A2** stage-level plan checkpointing — `1a1b5cc` - **B §4** architect contradiction-check (display-only) — `eae0a0c` *(follow-up: decision emit hook + in-session L3 embedding)* -- **D** dedicated `SourceFetched`/`LowQualityExtraction` events — `b098d87`; dynamic per-session egress allowlist — `027ff1f` *(follow-up: thread sessionId into `NetworkHostRule`; batch fetch-approval still unbuilt)* +- **D** dedicated `SourceFetched`/`LowQualityExtraction` events — `b098d87`; dynamic per-session egress allowlist — `027ff1f`, **activated** `ab7e4be` (session state threaded into assessment input); batch source-list fetch-approval — `4b0024f` - **B §5** reviewer static-first (runner + event + context-exclusion, live-wired) — `447fc7a` *(follow-up: a stage that emits `StaticFindingsRecordedEvent`)* - **G** narration-lane lag — `eff8f8d` - **E** tui-go go.mod directive fix — `1e6699a`; markdown rendering — `da72ee5`; session list / resume view — `1f7a5d9`; per-event render-matrix golden tests — `2a99e15`; approval ergonomics (queue + tier-gated confirm) — `d5afcd3` - **B §2** plan-derived diff manifest — `641051b` -**Remaining (deeper / lower-verifiability):** D batch fetch-approval (approval-flow surgery); H freestyle follow-ups (graph re-route, child-session runner); the 5 dormant follow-up activations (A1 planner prompt, A3 producer, B§4 decision hook, egress sessionId, B§5 static_check stage); §E plan-comparison view + idea-board→profile. +**Remaining (deeper / lower-verifiability):** H freestyle follow-ups (graph re-route, child-session runner); the dormant follow-up activations (A1 planner prompt, A3 producer, B§4 decision hook, B§5 static_check stage); §E plan-comparison view + idea-board→profile. **§E tui-go remaining (UNBLOCKED — Go via `GOTOOLCHAIN=auto` go1.24.2):** plan comparison view; idea-board→profile promotion. **Still blocked in this sandbox:** F live-QA gates + D §6 web approval client (need local model / SearXNG / GPU). @@ -105,11 +105,10 @@ web_search (T1) + web_fetch (T2) in `:infrastructure:tools`, `research.toml` - [ ] **§6 web approval client** — the one larger unbuilt piece. Third client on the existing Ktor REST+WS bus: approval cards + event strip + session list. Behind WireGuard/Tailscale only, never public. Independent track (spec ordering #5). -- [ ] **§3 batch fetch-approval at source-list level** — currently per-fetch T2; operator - should approve the source list once, not each URL. -- [ ] **Dedicated `SourceFetched` / `LowQualityExtraction` events** — quality + - content_sha256 currently ride inside `ToolExecutionCompletedEvent` metadata only. -- [ ] **Dynamic per-session egress allowlist** — currently static `networkAllowedHosts` + T2 gate. +- [x] **§3 batch fetch-approval at source-list level** — `4b0024f`. `POST /sessions/{id}/approve-sources` + → one `EgressHostsGrantedEvent` for the whole list. → RETRO. +- [x] **Dedicated `SourceFetched` / `LowQualityExtraction` events** — `b098d87`. → RETRO. +- [x] **Dynamic per-session egress allowlist** — `027ff1f`, activated `ab7e4be`. → RETRO. - [ ] **Fresh-machine runtime install** — copy `research.toml` + `prompts/` + `schemas/` and the `[[artifacts]]` / `[tools.research]` config entries into `~/.config/correx` (already done on this box; repo source is `examples/workflows/` + `docs/schemas/`). diff --git a/RETRO.md b/RETRO.md index 6b9ba798..9cc4dd4e 100644 --- a/RETRO.md +++ b/RETRO.md @@ -31,13 +31,15 @@ Nine tracks, each compiled + unit-tested green before commit. | `2a99e15` | tui-go per-event render-matrix golden tests (37 cases) + a coverage guard that parses `protocol.go` so any new event type without a golden fails | E §2 | | `d5afcd3` | tui-go approval ergonomics: navigable pending-approval queue (no modal stacking) + `y/n/e` keys + tier-gated T3+ arm/confirm | E §3 | | `641051b` | Plan-derived diff manifest: `PlanStage.writes` + `PlanDerivedManifest.deriveAllowedWrites`/`combine`, unioned into `StageConfig.writeManifest` at compile, enforced by the existing containment matcher | B §2 | +| `ab7e4be` | Egress allowlist **activation**: `resolveSessionEgressHosts` threads the session's `EgressAllowlistProjection` state into `ToolCallAssessmentInput` at `runPlane2Assessment`, so `NetworkHostRule` now consults the live per-session union (was fed `emptySet()`) | D (activation) | +| `4b0024f` | Batch source-list fetch-approval: `POST /sessions/{id}/approve-sources` → `SourceListHosts.extract` (java.net.URI, lower-cased, ports stripped, dossier-line tolerant) → one `EgressHostsGrantedEvent`; live allowlist then auto-clears those web_fetches (no per-URL T2) | D | ### Follow-ups created this pass (refiled into BACKLOG) - **C-A1 activation** — `planner.md` must emit a `brief_echo` block and `role_pipeline.toml` must set `briefEcho="true"` / `briefEchoSource` for the gate to fire live (model-dependent → live-QA). - **C-A3 producer** — nothing emits `CritiqueOutcomeCorrelatedEvent` yet; the calibration projection has no live feed until a reviewer-loop producer records finding outcomes. - **B §4 wiring** — `ArchitectContradictionChecker` is unwired: needs an architect-decision emit hook on the server side, and prior decisions embedded into L3 in-session (today they only land at session end). - **A §4 llama wiring** — `LlamaServerHealthProbe` is unregistered: an `HttpClient` + tokens/sec telemetry aren't exposed to the health-monitor scope yet (`// TODO(wiring)` in `Main.kt`). -- **D egress wiring** — `NetworkHostRule` consults the session set via a pure helper but is fed `emptySet()`: `ToolCallAssessmentInput` carries no `sessionId`, so going live needs session context threaded into the assessment input (`// TODO(wiring)` in `NetworkHostRule.kt`). Batch fetch-approval (approve a source list → one `EgressHostsGrantedEvent`) still unbuilt — needs approval-flow surgery. +- **D egress wiring** — ✅ DONE (`ab7e4be`): session egress state is now threaded into `ToolCallAssessmentInput` at `runPlane2Assessment`, so `NetworkHostRule` consults the live union. Batch fetch-approval ✅ DONE (`4b0024f`): `POST /sessions/{id}/approve-sources` emits one `EgressHostsGrantedEvent` for the whole source list. - **B §5 static_check stage** — runner/event/filter complete and the filter is live, but no workflow stage emits `StaticFindingsRecordedEvent` yet (`// TODO(wiring)` in `StaticAnalysisRunner.kt`); needs an event-emitting deterministic stage-type seam. - **E toolchain** — tui-go now builds via auto-downloaded go1.24.2 (`GOTOOLCHAIN=auto`). The committed directive fix assumes 1.26 was unintended; bump if a specific newer release is actually wanted. From f107ff554c0bd82e57a77ccfdf40797d6f2d86c9 Mon Sep 17 00:00:00 2001 From: kami Date: Sun, 21 Jun 2026 07:30:43 +0000 Subject: [PATCH 028/107] 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 /.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 --- .../apps/server/protocol/ClientMessage.kt | 8 ++ .../apps/server/ws/GlobalStreamHandler.kt | 56 ++++++++++ .../ServerMessageSerializationTest.kt | 8 ++ .../com/correx/core/config/ConfigLoader.kt | 43 +++++++- .../core/config/ProjectProfileWriter.kt | 64 +++++++++++ .../core/config/ProjectProfileWriterTest.kt | 79 ++++++++++++++ .../correx/core/events/events/IdeaEvents.kt | 16 +++ .../events/serialization/Serialization.kt | 2 + .../events/EventSerializationHardeningTest.kt | 7 ++ .../com/correx/core/router/IdeaReader.kt | 23 ++-- .../com/correx/core/router/IdeaReaderTest.kt | 100 ++++++++++++++++++ 11 files changed, 396 insertions(+), 10 deletions(-) create mode 100644 core/config/src/main/kotlin/com/correx/core/config/ProjectProfileWriter.kt create mode 100644 core/config/src/test/kotlin/com/correx/core/config/ProjectProfileWriterTest.kt create mode 100644 core/router/src/test/kotlin/com/correx/core/router/IdeaReaderTest.kt diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ClientMessage.kt b/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ClientMessage.kt index 1c796f69..550322e7 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ClientMessage.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ClientMessage.kt @@ -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 `/.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() diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/ws/GlobalStreamHandler.kt b/apps/server/src/main/kotlin/com/correx/apps/server/ws/GlobalStreamHandler.kt index 41d3c9e5..a850106b 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/ws/GlobalStreamHandler.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/ws/GlobalStreamHandler.kt @@ -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 + // /.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 = diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/protocol/ServerMessageSerializationTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/protocol/ServerMessageSerializationTest.kt index 8be8fa5c..85ec1665 100644 --- a/apps/server/src/test/kotlin/com/correx/apps/server/protocol/ServerMessageSerializationTest.kt +++ b/apps/server/src/test/kotlin/com/correx/apps/server/protocol/ServerMessageSerializationTest.kt @@ -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 = """ diff --git a/core/config/src/main/kotlin/com/correx/core/config/ConfigLoader.kt b/core/config/src/main/kotlin/com/correx/core/config/ConfigLoader.kt index 00352588..1dd5451c 100644 --- a/core/config/src/main/kotlin/com/correx/core/config/ConfigLoader.kt +++ b/core/config/src/main/kotlin/com/correx/core/config/ConfigLoader.kt @@ -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() } } diff --git a/core/config/src/main/kotlin/com/correx/core/config/ProjectProfileWriter.kt b/core/config/src/main/kotlin/com/correx/core/config/ProjectProfileWriter.kt new file mode 100644 index 00000000..40808484 --- /dev/null +++ b/core/config/src/main/kotlin/com/correx/core/config/ProjectProfileWriter.kt @@ -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 `/.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 = "[" + 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) +} diff --git a/core/config/src/test/kotlin/com/correx/core/config/ProjectProfileWriterTest.kt b/core/config/src/test/kotlin/com/correx/core/config/ProjectProfileWriterTest.kt new file mode 100644 index 00000000..c8ab2ead --- /dev/null +++ b/core/config/src/test/kotlin/com/correx/core/config/ProjectProfileWriterTest.kt @@ -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(" ")) + } +} diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/IdeaEvents.kt b/core/events/src/main/kotlin/com/correx/core/events/events/IdeaEvents.kt index 6f7629c9..f7a69346 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/events/IdeaEvents.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/events/IdeaEvents.kt @@ -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 + * (`/.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 diff --git a/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt b/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt index 2c394076..64a985ae 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt @@ -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) diff --git a/core/events/src/test/kotlin/com/correx/core/events/EventSerializationHardeningTest.kt b/core/events/src/test/kotlin/com/correx/core/events/EventSerializationHardeningTest.kt index cc9481b6..e3a75b92 100644 --- a/core/events/src/test/kotlin/com/correx/core/events/EventSerializationHardeningTest.kt +++ b/core/events/src/test/kotlin/com/correx/core/events/EventSerializationHardeningTest.kt @@ -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 diff --git a/core/router/src/main/kotlin/com/correx/core/router/IdeaReader.kt b/core/router/src/main/kotlin/com/correx/core/router/IdeaReader.kt index f9212085..d724c88a 100644 --- a/core/router/src/main/kotlin/com/correx/core/router/IdeaReader.kt +++ b/core/router/src/main/kotlin/com/correx/core/router/IdeaReader.kt @@ -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 { - val discarded = mutableSetOf() + val tombstoned = mutableSetOf() val captured = mutableListOf() 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 } diff --git a/core/router/src/test/kotlin/com/correx/core/router/IdeaReaderTest.kt b/core/router/src/test/kotlin/com/correx/core/router/IdeaReaderTest.kt new file mode 100644 index 00000000..0a48ae10 --- /dev/null +++ b/core/router/src/test/kotlin/com/correx/core/router/IdeaReaderTest.kt @@ -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) : EventStore { + private val stored: List = 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 = stored.asSequence() + + override suspend fun append(event: NewEvent): StoredEvent = throw NotImplementedError() + override suspend fun appendAll(events: List): List = throw NotImplementedError() + override fun read(sessionId: SessionId): List = throw NotImplementedError() + override fun readFrom(sessionId: SessionId, fromSequence: Long): List = + throw NotImplementedError() + override fun lastSequence(sessionId: SessionId): Long? = throw NotImplementedError() + override fun subscribe(sessionId: SessionId): Flow = emptyFlow() + override fun subscribeAll(): Flow = emptyFlow() + override suspend fun lastGlobalSequence(): Long = throw NotImplementedError() + override fun allSessionIds(): Set = throw NotImplementedError() + } +} From 1a707f0b7fa00810cfc521c77bfd6caf452bd05c Mon Sep 17 00:00:00 2001 From: kami Date: Sun, 21 Jun 2026 07:32:49 +0000 Subject: [PATCH 029/107] docs(backlog): record idea-promotion (f107ff5); mark plan-comparison blocked f107ff5 (idea-board -> profile promotion) moves to RETRO. Plan-comparison view marked BLOCKED: no plan-candidate event exists server-side (only workflow.proposed), so there is no wire shape to render or unit-verify until a multi-candidate planner lands. Co-Authored-By: Claude Opus 4.8 --- BACKLOG.md | 16 ++++++++++------ RETRO.md | 1 + 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/BACKLOG.md b/BACKLOG.md index 17ceb556..2a3cc6f8 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -40,7 +40,7 @@ matching bullets below are superseded; only the noted follow-ups remain live: - **B §2** plan-derived diff manifest — `641051b` -**Remaining (deeper / lower-verifiability):** H freestyle follow-ups (graph re-route, child-session runner); the dormant follow-up activations (A1 planner prompt, A3 producer, B§4 decision hook, B§5 static_check stage); §E plan-comparison view + idea-board→profile. +**Remaining (deeper / lower-verifiability):** B§4 decision hook (next); H freestyle follow-ups (graph re-route, child-session runner — deep session-lifecycle, want live testing); the dormant model-dependent activations (A1 planner prompt, A3 producer, B§5 static_check stage — live-QA-gated). §E plan-comparison view is BLOCKED (no plan-candidate event; see §E). **§E tui-go remaining (UNBLOCKED — Go via `GOTOOLCHAIN=auto` go1.24.2):** plan comparison view; idea-board→profile promotion. **Still blocked in this sandbox:** F live-QA gates + D §6 web approval client (need local model / SearXNG / GPU). @@ -121,16 +121,20 @@ card (65df3f4) are shipped — RETRO candidates. Remaining: - [ ] **§2 full Go↔Kotlin per-event-type render matrix** — golden-fixture tests asserting a defined render for every event type (last §2 bullet; partially covered today). -- [ ] **§3/§4 plan comparison view** — top-2 candidates side-by-side, compact stage graphs, - diff-highlighted, lint/critique findings inline. Lands with the planning pipeline; - load-bearing for the future preference ranker. +- 🚫 **§3/§4 plan comparison view** — **BLOCKED (no data source).** Top-2 candidates side-by-side, + compact stage graphs, diff-highlighted, lint/critique findings inline. The server emits no + plan-candidate event — only `workflow.proposed` (candidate *workflows*, not *plans*). Per the + spec this "lands with the planning pipeline" (a multi-candidate planner), which is unbuilt, so + there is no wire shape to render against and nothing to unit-verify. Do NOT build a blind TUI + view; revisit only once a `PlanCandidate`-bearing event exists. (Checked 2026-06-21.) - [ ] **§3 approval ergonomics audit** — single-key y/n/e for T0–T2, mandatory confirm T3+, queued-approval navigable list (never modal-stacked). Verify against current band impl. - [ ] **Render markdown in chat turns** (QA feature note b) — TUI currently ignores md. - [ ] **Session list / resume view** (QA feature note c) — after server restart + TUI reopen there's no way to see a prior session or its state; `GET /sessions` data exists server-side. -- [ ] **Idea board → profile promotion** — deferred hook; auto-captured ideas promote into the - curated `.correx/project.toml` profile. (Board itself shipped 3ac0a14..02e963d.) +- [x] **Idea board → profile promotion** — `f107ff5`. `PromoteIdea` WS message → append idea as a + `conventions` entry in `.correx/project.toml` (`ProjectProfileWriter`), `IdeaPromotedEvent` + tombstones it off the board. → RETRO. - ⏸ **§5.2 command-card LLM annotation layer** — deferred by spec ("ship layer 1, live with it, then decide whether layer 2 earns its place"). Build only after using layer 1. diff --git a/RETRO.md b/RETRO.md index 9cc4dd4e..9f622101 100644 --- a/RETRO.md +++ b/RETRO.md @@ -33,6 +33,7 @@ Nine tracks, each compiled + unit-tested green before commit. | `641051b` | Plan-derived diff manifest: `PlanStage.writes` + `PlanDerivedManifest.deriveAllowedWrites`/`combine`, unioned into `StageConfig.writeManifest` at compile, enforced by the existing containment matcher | B §2 | | `ab7e4be` | Egress allowlist **activation**: `resolveSessionEgressHosts` threads the session's `EgressAllowlistProjection` state into `ToolCallAssessmentInput` at `runPlane2Assessment`, so `NetworkHostRule` now consults the live per-session union (was fed `emptySet()`) | D (activation) | | `4b0024f` | Batch source-list fetch-approval: `POST /sessions/{id}/approve-sources` → `SourceListHosts.extract` (java.net.URI, lower-cased, ports stripped, dossier-line tolerant) → one `EgressHostsGrantedEvent`; live allowlist then auto-clears those web_fetches (no per-URL T2) | D | +| `f107ff5` | Idea-board → project-profile promotion: `IdeaPromotedEvent` (board tombstone, like discard) + `ProjectProfileWriter` (escape-aware TOML serialize/write) + `ProjectProfile.withConvention` (deduped) + server `PromoteIdea` handler (loads profile, appends idea text, writes `.correx/project.toml`); also made `SimpleToml`'s reader unescape `\\`/`\"` symmetrically with the writers (fixes pre-existing read/write asymmetry) | E | ### Follow-ups created this pass (refiled into BACKLOG) - **C-A1 activation** — `planner.md` must emit a `brief_echo` block and `role_pipeline.toml` must set `briefEcho="true"` / `briefEchoSource` for the gate to fire live (model-dependent → live-QA). From 4e085100450182fa83db48111376d05286ec0711 Mon Sep 17 00:00:00 2001 From: kami Date: Sun, 21 Jun 2026 08:07:33 +0000 Subject: [PATCH 030/107] =?UTF-8?q?feat(server):=20activate=20architect=20?= =?UTF-8?q?contradiction-check=20hook=20(B=C2=A74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the display-only ArchitectContradictionChecker (eae0a0c) live: ServerModule.start() subscribes to the architect stage's `design` ArtifactCreatedEvent, resolves the decision text (prefers the design schema's `approach` field; falls back to the capped artifact text), runs checker.check(...), and appends the non-null PossibleContradictionFlaggedEvent. Live-only and never halts a stage (runCatching). Checker built in Main.kt gated on project.enabled (same L3 "project:" namespace ProjectMemoryService.persist populates) and threaded into ServerModule as a nullable param. Replaces the standing TODO(wiring) in Main.kt. Co-Authored-By: Claude Opus 4.8 --- .../kotlin/com/correx/apps/server/Main.kt | 21 +- .../com/correx/apps/server/ServerModule.kt | 95 ++++++ .../server/ArchitectContradictionHookTest.kt | 276 ++++++++++++++++++ 3 files changed, 382 insertions(+), 10 deletions(-) create mode 100644 apps/server/src/test/kotlin/com/correx/apps/server/ArchitectContradictionHookTest.kt diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt index 4cbc18e5..ea2cdad8 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt @@ -380,16 +380,16 @@ fun main() { privilegedLocations = privilegedLocations, allowedWorkspaceRoots = allowedWorkspaceRoots, ) - // TODO(wiring): ArchitectContradictionChecker (BACKLOG §B-§4) is complete but not yet live. - // `embedder` + `l3MemoryStore` are in scope here, but two preconditions are missing: - // (1) no server-side hook exposes the architect's decision *text* with an event-emit path - // (architect is a workflow-defined role/stage, not a known stage id; decisions become - // DecisionRecords via the journal reducer from generic events), and - // (2) decisions are only embedded into L3 at session end (ProjectMemoryService.persist), - // under turnId "project:", so an in-session architect run has nothing of its - // own to retrieve. Wiring this would mean inventing an architect-stage subscriber + - // emit path — deliberately left out per scope. To go live: subscribe to the architect - // decision, call ArchitectContradictionChecker.check(...), and emit the non-null result. + // Display-only architect contradiction surfacing (BACKLOG §B-§4): now live via the + // ServerModule.start() subscription on the architect's `design` ArtifactCreatedEvent (see + // ServerModule.handleArchitectArtifact). Gated on project.enabled because the checker queries the + // same L3 "project:" namespace that ProjectMemoryService.persist populates. + val architectContradictionChecker: com.correx.apps.server.memory.ArchitectContradictionChecker? = + if (correxConfig.project.enabled) { + com.correx.apps.server.memory.ArchitectContradictionChecker(embedder, l3MemoryStore) + } else { + null + } // Built from a config snapshot and reused by ConfigService's rebuild hook so toggling // project.enabled / personalization.* applies live to the next session. fun buildProjectMemory(cfg: CorrexConfig): com.correx.apps.server.memory.ProjectMemoryService? = @@ -477,6 +477,7 @@ fun main() { workspaceResolver = workspaceResolver, narrationMaxPerRun = correxConfig.router.narration.maxPerRun, projectMemory = projectMemory, + architectContradictionChecker = architectContradictionChecker, configHolder = configHolder, freestyleDriver = freestyleDriver, operatorProfile = operatorProfile, diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt b/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt index c5ea422f..269395a5 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt @@ -13,11 +13,15 @@ import com.correx.core.approvals.DefaultApprovalRepository import com.correx.core.artifactstore.ArtifactStore import com.correx.core.config.OperatorProfile import com.correx.core.config.ProjectProfileLoader +import com.correx.apps.server.memory.ArchitectContradictionChecker import com.correx.core.events.events.ApprovalRequestedEvent +import com.correx.core.events.events.ArtifactContentStoredEvent +import com.correx.core.events.events.ArtifactCreatedEvent import com.correx.core.events.events.EventMetadata import com.correx.core.events.events.NewEvent import com.correx.core.events.events.ExecutionPlanLockedEvent import com.correx.core.events.events.OperatorProfileBoundEvent +import com.correx.core.events.events.PossibleContradictionFlaggedEvent import com.correx.core.events.events.ProjectProfileBoundEvent import com.correx.core.events.events.OrchestrationPausedEvent import com.correx.core.events.events.OrchestrationResumedEvent @@ -26,6 +30,9 @@ import com.correx.core.events.stores.EventStore import com.correx.core.events.types.EventId import com.correx.core.events.types.SessionId import com.correx.core.events.types.StageId +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonObject import com.correx.core.kernel.execution.WorkflowResult import com.correx.core.kernel.orchestration.DefaultSessionOrchestrator import com.correx.core.kernel.orchestration.OrchestrationConfig @@ -87,6 +94,10 @@ class ServerModule( // Cross-session, repo-scoped memory. Null disables the hooks entirely (tests / static path). // Rebuilt by ConfigService when project.enabled is toggled live. projectMemory: com.correx.apps.server.memory.ProjectMemoryService? = null, + // Display-only architect contradiction surfacing (BACKLOG §B-§4). Null disables the hook + // (tests / project.enabled=false). Unlike projectMemory there is no live config-rebuild hook: + // a server restart re-reads project.enabled, which is enough for this informational flag. + private val architectContradictionChecker: ArchitectContradictionChecker? = null, // Live, swappable config. Null only in tests that don't exercise config editing; defaults to a // holder seeded from defaults so callers always have a value to read. val configHolder: com.correx.core.config.ConfigHolder = @@ -187,6 +198,80 @@ class ServerModule( // Continuous, edge-triggered health watch (observability-spec §4). Live-only like narration: // probes read the environment and record degraded/restored events; replay reads those facts. healthMonitor?.start(moduleScope) + + // Display-only architect contradiction surfacing (BACKLOG §B-§4). Live-only like the hooks + // above: subscribeAll() replays nothing and ServerModule is never built under replay, so a + // restart never re-fires the flag (invariant #8). Never halts a stage — failures are logged + // and swallowed (runCatching) and the flag is emitted purely for the operator to eyeball. + architectContradictionChecker?.let { checker -> + eventStore.subscribeAll() + .filter { + it.payload is ArtifactCreatedEvent && + (it.payload as ArtifactCreatedEvent).stageId.value == ARCHITECT_STAGE_ID + } + .onEach { stored -> + runCatching { + handleArchitectArtifact(checker, stored.payload as ArtifactCreatedEvent) + }.onFailure { log.warn("architect contradiction check failed: {}", it.message) } + } + .launchIn(moduleScope) + } + } + + /** + * Runs the architect contradiction check for a freshly-created `design` artifact and, when the + * checker surfaces a non-null flag, appends the [PossibleContradictionFlaggedEvent]. Display-only: + * the flag is informational and never halts the stage. Factored out of the subscription so the + * extraction/dispatch path is unit-testable without a live event flow. + * + * @return the appended flag, or null when the artifact text could not be resolved or the checker + * found nothing near the architect's decision. + */ + internal suspend fun handleArchitectArtifact( + checker: ArchitectContradictionChecker, + event: ArtifactCreatedEvent, + ): PossibleContradictionFlaggedEvent? { + val decisionText = resolveArchitectDecisionText(event) ?: return null + val flag = checker.check(event.sessionId, event.stageId, decisionText) ?: return null + eventStore.append( + NewEvent( + metadata = EventMetadata( + eventId = EventId(java.util.UUID.randomUUID().toString()), + sessionId = event.sessionId, + timestamp = Clock.System.now(), + schemaVersion = 1, + causationId = null, + correlationId = null, + ), + payload = flag, + ), + ) + return flag + } + + /** + * Resolves the architect's decision text for [event] from the CAS-stored `design` artifact. + * Finds the content hash by scanning the session for the [ArtifactContentStoredEvent] matching + * the artifact id (emitted at inference time, before [ArtifactCreatedEvent]), fetches the bytes, + * and prefers the design schema's `approach` field ("the chosen approach and why") as the + * decision summary; falls back to the whole artifact text, capped at [DECISION_TEXT_CAP] chars. + * + * @return the decision text, or null when no content hash, bytes, or text could be resolved. + */ + private suspend fun resolveArchitectDecisionText(event: ArtifactCreatedEvent): String? { + val contentHash = eventStore.read(event.sessionId) + .asSequence() + .map { it.payload } + .filterIsInstance() + .firstOrNull { it.artifactId == event.artifactId } + ?.contentHash ?: return null + val text = artifactStore.get(contentHash)?.toString(Charsets.UTF_8)?.takeIf { it.isNotBlank() } + ?: return null + val approach = runCatching { + (lenientJson.parseToJsonElement(text).jsonObject["approach"] as? JsonPrimitive) + ?.takeIf { it.isString }?.content + }.getOrNull()?.takeIf { it.isNotBlank() } + return (approach ?: text).take(DECISION_TEXT_CAP) } /** @@ -438,4 +523,14 @@ class ServerModule( .forEach { req -> approvalCoordinator.registerPendingRequest(req.id, sessionId) } } } + + companion object { + /** Workflow stage id that produces the `design` artifact (examples/workflows/role_pipeline.toml). */ + const val ARCHITECT_STAGE_ID = "architect" + + /** Cap on the architect decision text embedded when no `approach` field is present. */ + private const val DECISION_TEXT_CAP = 4000 + + private val lenientJson = Json { ignoreUnknownKeys = true; isLenient = true } + } } diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/ArchitectContradictionHookTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/ArchitectContradictionHookTest.kt new file mode 100644 index 00000000..a07b3289 --- /dev/null +++ b/apps/server/src/test/kotlin/com/correx/apps/server/ArchitectContradictionHookTest.kt @@ -0,0 +1,276 @@ +package com.correx.apps.server + +import com.correx.apps.server.memory.ArchitectContradictionChecker +import com.correx.apps.server.registry.ProviderRegistry +import com.correx.apps.server.registry.WorkflowRegistry +import com.correx.apps.server.registry.WorkflowSummary +import com.correx.apps.server.undo.SessionUndoService +import com.correx.core.artifactstore.ArtifactStore +import com.correx.core.context.builder.DefaultContextPackBuilder +import com.correx.core.context.compression.DefaultContextCompressor +import com.correx.core.events.EventDispatcher +import com.correx.core.events.events.ArtifactContentStoredEvent +import com.correx.core.events.events.ArtifactCreatedEvent +import com.correx.core.events.events.EventMetadata +import com.correx.core.events.events.EventPayload +import com.correx.core.events.events.NewEvent +import com.correx.core.events.events.PossibleContradictionFlaggedEvent +import com.correx.core.events.stores.EventStore +import com.correx.core.events.types.ArtifactId +import com.correx.core.events.types.EventId +import com.correx.core.events.types.ProviderId +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.inference.Embedder +import com.correx.core.inference.InferenceProjector +import com.correx.core.inference.InferenceProvider +import com.correx.core.inference.InferenceRepository +import com.correx.core.inference.ProviderHealth +import com.correx.core.kernel.orchestration.DefaultOrchestrationReducer +import com.correx.core.kernel.orchestration.DefaultSessionOrchestrator +import com.correx.core.kernel.orchestration.OrchestrationConfig +import com.correx.core.kernel.orchestration.OrchestrationProjector +import com.correx.core.kernel.orchestration.OrchestrationRepository +import com.correx.core.kernel.orchestration.OrchestratorEngines +import com.correx.core.kernel.orchestration.OrchestratorRepositories +import com.correx.core.kernel.retry.DefaultRetryCoordinator +import com.correx.core.risk.DefaultRiskAssessor +import com.correx.core.router.l3.L3Hit +import com.correx.core.router.l3.L3MemoryEntry +import com.correx.core.router.l3.L3MemoryStore +import com.correx.core.router.l3.L3Query +import com.correx.core.router.model.RouterConfig +import com.correx.core.sessions.DefaultSessionReducer +import com.correx.core.sessions.DefaultSessionRepository +import com.correx.core.sessions.SessionProjector +import com.correx.core.sessions.projections.replay.DefaultEventReplayer +import com.correx.core.transitions.graph.WorkflowGraph +import com.correx.core.transitions.resolution.DefaultTransitionResolver +import com.correx.core.validation.pipeline.ValidationPipeline +import com.correx.infrastructure.InfrastructureModule +import com.correx.infrastructure.inference.commons.UnavailableProbe +import com.correx.infrastructure.persistence.InMemoryEventStore +import com.correx.infrastructure.tools.FileEditConfig +import com.correx.infrastructure.tools.FileReadConfig +import com.correx.infrastructure.tools.FileWriteConfig +import com.correx.infrastructure.tools.ShellConfig +import com.correx.infrastructure.tools.ToolConfig +import kotlinx.coroutines.runBlocking +import kotlinx.datetime.Clock +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Path +import java.util.UUID + +/** Always returns the same vector so the canned L3 store fully controls the scores. */ +private class OnesEmbedder(override val dimension: Int = 8) : Embedder { + override suspend fun embed(text: String): FloatArray = FloatArray(dimension) { 1f } +} + +/** Returns canned hits regardless of the query vector. */ +private class CannedL3MemoryStore(private val hits: List) : L3MemoryStore { + override suspend fun store(entry: L3MemoryEntry) = Unit + override suspend fun query(query: L3Query): List = hits.sortedByDescending { it.score }.take(query.k) + override suspend fun existsByTurnIdPrefix(prefix: String): Boolean = hits.any { it.entry.turnId.startsWith(prefix) } + override suspend fun close() = Unit +} + +/** Minimal in-memory CAS keyed by content hash. */ +private class MapArtifactStore(private val bytesByHash: Map) : ArtifactStore { + override suspend fun put(bytes: ByteArray): ArtifactId = ArtifactId("00".repeat(32)) + override suspend fun get(id: ArtifactId): ByteArray? = bytesByHash[id.value] + override suspend fun flushBefore(commit: suspend () -> Unit) { commit() } +} + +/** + * Exercises [ServerModule.handleArchitectArtifact]: the extraction/dispatch helper that backs the + * display-only architect contradiction subscription (BACKLOG §B-§4). A full subscription/integration + * test is not required — these assert the helper fetches the design artifact text and emits (or + * skips) the flag. The surrounding module is built with in-memory infra (no real model needed). + */ +class ArchitectContradictionHookTest { + + private val session = SessionId("new-session") + private val priorSession = SessionId("prior-session") + private val architectStage = StageId("architect") + private val designArtifact = ArtifactId("design") + private val contentHash = ArtifactId("cafebabe") + + private fun append(es: EventStore, payload: EventPayload, sid: SessionId = session) = runBlocking { + es.append( + NewEvent( + EventMetadata(EventId(UUID.randomUUID().toString()), sid, Clock.System.now(), 1, null, null), + payload, + ), + ) + } + + private fun priorDecisionHit(text: String, score: Float) = L3Hit( + entry = L3MemoryEntry( + id = UUID.randomUUID().toString(), + sessionId = priorSession, + turnId = "project:/repo", + text = text, + vector = FloatArray(8) { 1f }, + timestampMs = 0L, + ), + score = score, + ) + + private fun flagsIn(es: EventStore): List = + es.read(session).map { it.payload }.filterIsInstance() + + private fun buildModule( + eventStore: EventStore, + artifactStore: ArtifactStore, + checker: ArchitectContradictionChecker?, + tempDir: Path, + ): ServerModule { + val provider = InfrastructureModule.createLlamaCppProvider( + modelId = "test-model", + modelPath = "/dev/null", + baseUrl = "http://127.0.0.1:1", + ) + val providerRegistry = InfrastructureModule.createProviderRegistry(listOf(provider)) + val inferenceRouter = com.correx.core.inference.DefaultInferenceRouter( + providerRegistry, + com.correx.infrastructure.inference.FirstAvailableRoutingStrategy(), + ) + val toolConfig = ToolConfig( + shell = ShellConfig(enabled = false, allowedExecutables = emptySet(), workingDir = tempDir), + fileRead = FileReadConfig(enabled = false, allowedPaths = setOf(tempDir)), + fileWrite = FileWriteConfig(enabled = false, allowedPaths = setOf(tempDir), workingDir = tempDir), + fileEdit = FileEditConfig(enabled = false, allowedPaths = setOf(tempDir), workingDir = tempDir), + ) + val toolRegistry = InfrastructureModule.createToolRegistry(toolConfig) + val toolExecutor = InfrastructureModule.createToolExecutor( + registry = toolRegistry, + eventDispatcher = EventDispatcher(eventStore), + workDir = tempDir, + artifactStore = null, + ) + val engines = OrchestratorEngines( + transitionResolver = DefaultTransitionResolver { condition, ctx -> condition.evaluate(ctx) }, + contextPackBuilder = DefaultContextPackBuilder(DefaultContextCompressor()), + inferenceRouter = inferenceRouter, + validationPipeline = ValidationPipeline(validators = emptyList()), + approvalEngine = com.correx.core.approvals.domain.DefaultApprovalEngine(), + riskAssessor = DefaultRiskAssessor(), + toolRegistry = toolRegistry, + toolExecutor = toolExecutor, + ) + val repositories = OrchestratorRepositories( + eventStore = eventStore, + inferenceRepository = InferenceRepository(DefaultEventReplayer(eventStore, InferenceProjector())), + orchestrationRepository = OrchestrationRepository( + DefaultEventReplayer(eventStore, OrchestrationProjector(DefaultOrchestrationReducer())), + ), + sessionRepository = DefaultSessionRepository( + DefaultEventReplayer(eventStore, SessionProjector(DefaultSessionReducer())), + ), + artifactRepository = InfrastructureModule.createArtifactRepository(eventStore), + approvalRepository = InfrastructureModule.createApprovalRepository(eventStore), + ) + val orchestrator = DefaultSessionOrchestrator( + repositories = repositories, + engines = engines, + retryCoordinator = DefaultRetryCoordinator(eventStore), + artifactStore = artifactStore, + tokenizer = provider.tokenizer, + decisionJournalRepository = InfrastructureModule.createDecisionJournalRepository(eventStore), + ) + val routerFacade = InfrastructureModule.createRouterFacade( + eventStore = eventStore, + inferenceRouter = inferenceRouter, + config = RouterConfig(), + tokenizer = provider.tokenizer, + ) + val noopProviderRegistry = object : ProviderRegistry { + override fun listAll() = emptyList() + override suspend fun healthCheckAll() = emptyMap() + } + val noopWorkflowRegistry = object : WorkflowRegistry { + override fun listAll(): List = emptyList() + override fun find(workflowId: String): WorkflowGraph? = null + } + return ServerModule( + orchestrator = orchestrator, + eventStore = eventStore, + artifactStore = artifactStore, + sessionRepository = repositories.sessionRepository, + workflowRegistry = noopWorkflowRegistry, + providerRegistry = noopProviderRegistry, + defaultOrchestrationConfig = OrchestrationConfig(sandboxRoot = tempDir), + routerFacade = routerFacade, + orchestrationRepository = repositories.orchestrationRepository, + approvalRepository = repositories.approvalRepository, + toolRegistry = toolRegistry, + sessionUndoService = SessionUndoService(eventStore, artifactStore, bootRoots = setOf(tempDir)), + resourceProbe = UnavailableProbe, + architectContradictionChecker = checker, + ) + } + + @Test + fun `emits the flag when the checker surfaces a near prior decision`(@TempDir tempDir: Path) = runBlocking { + val es = InMemoryEventStore() + val designJson = """{"approach":"Use Postgres for the event store.","components":["db.kt"]}""" + val artifacts = MapArtifactStore(mapOf(contentHash.value to designJson.toByteArray())) + // ArtifactContentStored precedes ArtifactCreated for the same artifactId (inference-time). + append(es, ArtifactContentStoredEvent(designArtifact, contentHash, session, architectStage)) + val created = ArtifactCreatedEvent(designArtifact, session, architectStage, schemaVersion = 1) + append(es, created) + val checker = ArchitectContradictionChecker( + OnesEmbedder(), + CannedL3MemoryStore(listOf(priorDecisionHit("Decided to use SQLite for the event store.", 0.9f))), + ) + + val flag = buildModule(es, artifacts, checker, tempDir).handleArchitectArtifact(checker, created) + + assertNotNull(flag, "expected a contradiction flag") + assertEquals(session, flag!!.sessionId) + assertEquals(architectStage, flag.stageId) + // The design schema's `approach` field is preferred as the decision text. + assertEquals("Use Postgres for the event store.", flag.decisionSummary) + assertEquals("Decided to use SQLite for the event store.", flag.related.single().summary) + // The flag is appended to the session stream (display-only, but recorded for the operator). + assertEquals(1, flagsIn(es).size) + } + + @Test + fun `returns null and appends nothing when the checker finds no related decision`( + @TempDir tempDir: Path, + ) = runBlocking { + val es = InMemoryEventStore() + val designJson = """{"approach":"Use Postgres for the event store.","components":["db.kt"]}""" + val artifacts = MapArtifactStore(mapOf(contentHash.value to designJson.toByteArray())) + append(es, ArtifactContentStoredEvent(designArtifact, contentHash, session, architectStage)) + val created = ArtifactCreatedEvent(designArtifact, session, architectStage, schemaVersion = 1) + append(es, created) + val checker = ArchitectContradictionChecker(OnesEmbedder(), CannedL3MemoryStore(emptyList())) + + val flag = buildModule(es, artifacts, checker, tempDir).handleArchitectArtifact(checker, created) + + assertNull(flag) + assertEquals(0, flagsIn(es).size) + } + + @Test + fun `returns null when no content hash was recorded for the artifact`(@TempDir tempDir: Path) = runBlocking { + val es = InMemoryEventStore() + val created = ArtifactCreatedEvent(designArtifact, session, architectStage, schemaVersion = 1) + append(es, created) + val checker = ArchitectContradictionChecker( + OnesEmbedder(), + CannedL3MemoryStore(listOf(priorDecisionHit("Decided to use SQLite.", 0.9f))), + ) + + val flag = buildModule(es, MapArtifactStore(emptyMap()), checker, tempDir) + .handleArchitectArtifact(checker, created) + + assertNull(flag) + } +} From 3302930b006240820b8af6b970f2aded7a78a29c Mon Sep 17 00:00:00 2001 From: kami Date: Sun, 21 Jun 2026 08:08:14 +0000 Subject: [PATCH 031/107] =?UTF-8?q?docs(backlog):=20record=20B=C2=A74=20ac?= =?UTF-8?q?tivation=20(4e08510)=20in=20RETRO?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Architect contradiction-check is now live (server subscribes to the architect design artifact and emits the display-only flag). All cleanly unit-verifiable deterministic backlog tracks are now landed; what remains is model-dependent activations (live-QA gated) and deep session-lifecycle work (§H) better done with live testing. Co-Authored-By: Claude Opus 4.8 --- BACKLOG.md | 9 +++++---- RETRO.md | 3 ++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/BACKLOG.md b/BACKLOG.md index 2a3cc6f8..19f1731b 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -32,7 +32,7 @@ matching bullets below are superseded; only the noted follow-ups remain live: - **B §6 + C-A3** `CritiqueFinding` type + calibration projection — `f783eed` *(follow-up: `CritiqueOutcomeCorrelatedEvent` producer)* - **C-A1** brief echo-back gate — `1df7af5` *(follow-up: `planner.md` brief_echo + `role_pipeline` metadata to activate)* - **C-A2** stage-level plan checkpointing — `1a1b5cc` -- **B §4** architect contradiction-check (display-only) — `eae0a0c` *(follow-up: decision emit hook + in-session L3 embedding)* +- **B §4** architect contradiction-check (display-only) — `eae0a0c`, **activated** `4e08510` (server subscribes to the architect `design` artifact → emits the flag) - **D** dedicated `SourceFetched`/`LowQualityExtraction` events — `b098d87`; dynamic per-session egress allowlist — `027ff1f`, **activated** `ab7e4be` (session state threaded into assessment input); batch source-list fetch-approval — `4b0024f` - **B §5** reviewer static-first (runner + event + context-exclusion, live-wired) — `447fc7a` *(follow-up: a stage that emits `StaticFindingsRecordedEvent`)* - **G** narration-lane lag — `eff8f8d` @@ -40,7 +40,7 @@ matching bullets below are superseded; only the noted follow-ups remain live: - **B §2** plan-derived diff manifest — `641051b` -**Remaining (deeper / lower-verifiability):** B§4 decision hook (next); H freestyle follow-ups (graph re-route, child-session runner — deep session-lifecycle, want live testing); the dormant model-dependent activations (A1 planner prompt, A3 producer, B§5 static_check stage — live-QA-gated). §E plan-comparison view is BLOCKED (no plan-candidate event; see §E). +**Remaining (deeper / lower-verifiability):** H freestyle follow-ups (graph re-route, child-session runner — deep session-lifecycle, want live testing); the dormant model-dependent activations (A1 planner prompt, A3 producer, B§5 static_check stage — live-QA-gated, deprioritized to last). §E plan-comparison view is BLOCKED (no plan-candidate event; see §E). All cleanly unit-verifiable deterministic tracks are now landed. **§E tui-go remaining (UNBLOCKED — Go via `GOTOOLCHAIN=auto` go1.24.2):** plan comparison view; idea-board→profile promotion. **Still blocked in this sandbox:** F live-QA gates + D §6 web approval client (need local model / SearXNG / GPU). @@ -71,8 +71,9 @@ and §5 reviewer prompt/needs wiring (3467826) are shipped — RETRO candidates. structured type. Shared by plan critic + code reviewer. - [ ] **§6 `CriticCalibrationProjection`** (keyed by modelHash, role) — needs runtime history to exist first (spec ordering #5). Same projection as pipeline-addenda A3. -- [ ] **§4 architect contradiction-check** — L3 retrieval over decision journal + ADRs → - `PossibleContradictionFlag` (display-only in v1). Unbuilt. +- [x] **§4 architect contradiction-check** — `eae0a0c` (checker) + `4e08510` (live server hook: + subscribe to architect `design` artifact → `check` → emit `PossibleContradictionFlaggedEvent`, + display-only). → RETRO. - [ ] **§3 symbol grounding** — deferred; needs a real symbol index (file-path grounding done). - [ ] **§2 dynamic plan-derived diff manifest** — allowed files from the `impl_plan` artifact. Future refinement; static per-stage `writes = [globs]` form is shipped. diff --git a/RETRO.md b/RETRO.md index 9f622101..31336a8f 100644 --- a/RETRO.md +++ b/RETRO.md @@ -34,11 +34,12 @@ Nine tracks, each compiled + unit-tested green before commit. | `ab7e4be` | Egress allowlist **activation**: `resolveSessionEgressHosts` threads the session's `EgressAllowlistProjection` state into `ToolCallAssessmentInput` at `runPlane2Assessment`, so `NetworkHostRule` now consults the live per-session union (was fed `emptySet()`) | D (activation) | | `4b0024f` | Batch source-list fetch-approval: `POST /sessions/{id}/approve-sources` → `SourceListHosts.extract` (java.net.URI, lower-cased, ports stripped, dossier-line tolerant) → one `EgressHostsGrantedEvent`; live allowlist then auto-clears those web_fetches (no per-URL T2) | D | | `f107ff5` | Idea-board → project-profile promotion: `IdeaPromotedEvent` (board tombstone, like discard) + `ProjectProfileWriter` (escape-aware TOML serialize/write) + `ProjectProfile.withConvention` (deduped) + server `PromoteIdea` handler (loads profile, appends idea text, writes `.correx/project.toml`); also made `SimpleToml`'s reader unescape `\\`/`\"` symmetrically with the writers (fixes pre-existing read/write asymmetry) | E | +| `4e08510` | Architect contradiction-check **activation**: `ServerModule.start()` subscribes to the architect stage's `design` `ArtifactCreatedEvent`, resolves decision text (design-schema `approach` field, capped fallback), runs `ArchitectContradictionChecker.check`, appends the non-null `PossibleContradictionFlaggedEvent`. Live-only, never halts. Checker built in `Main.kt` (gated on `project.enabled`) and threaded into `ServerModule`. Replaces the `Main.kt` `TODO(wiring)` | B §4 (activation) | ### Follow-ups created this pass (refiled into BACKLOG) - **C-A1 activation** — `planner.md` must emit a `brief_echo` block and `role_pipeline.toml` must set `briefEcho="true"` / `briefEchoSource` for the gate to fire live (model-dependent → live-QA). - **C-A3 producer** — nothing emits `CritiqueOutcomeCorrelatedEvent` yet; the calibration projection has no live feed until a reviewer-loop producer records finding outcomes. -- **B §4 wiring** — `ArchitectContradictionChecker` is unwired: needs an architect-decision emit hook on the server side, and prior decisions embedded into L3 in-session (today they only land at session end). +- **B §4 wiring** — ✅ DONE (`4e08510`): `ServerModule.start()` now subscribes to the architect `design` artifact and emits the flag. (In-session L3 embedding is NOT needed — the checker filters to PRIOR sessions by design, so it works against prior runs' distilled decisions.) - **A §4 llama wiring** — `LlamaServerHealthProbe` is unregistered: an `HttpClient` + tokens/sec telemetry aren't exposed to the health-monitor scope yet (`// TODO(wiring)` in `Main.kt`). - **D egress wiring** — ✅ DONE (`ab7e4be`): session egress state is now threaded into `ToolCallAssessmentInput` at `runPlane2Assessment`, so `NetworkHostRule` consults the live union. Batch fetch-approval ✅ DONE (`4b0024f`): `POST /sessions/{id}/approve-sources` emits one `EgressHostsGrantedEvent` for the whole source list. - **B §5 static_check stage** — runner/event/filter complete and the filter is live, but no workflow stage emits `StaticFindingsRecordedEvent` yet (`// TODO(wiring)` in `StaticAnalysisRunner.kt`); needs an event-emitting deterministic stage-type seam. From 64a90e74a9768d055788cc5245697fd05c21f0c2 Mon Sep 17 00:00:00 2001 From: kami Date: Sun, 21 Jun 2026 08:25:25 +0000 Subject: [PATCH 032/107] =?UTF-8?q?feat(health):=20register=20LlamaServerH?= =?UTF-8?q?ealthProbe=20(A=C2=A74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the built-but-unregistered LlamaServerHealthProbe (266bbf0) into the health monitor: when a `llamacpp` provider URL is configured, add an HttpLlamaLivenessClient-backed probe (dedicated CIO client, 3s per-ping timeout, shutdown-hook close) to the probe list. Gated on a configured provider URL so a model-less box never reports a spurious LLAMA_SERVER DEGRADED. Replaces the Main.kt TODO(wiring). main() is not unit-tested; the probe + client are already covered by 266bbf0 — compile + :apps:server:test + detekt green. Co-Authored-By: Claude Opus 4.8 --- .../kotlin/com/correx/apps/server/Main.kt | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt index ea2cdad8..97ba6a80 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt @@ -96,6 +96,9 @@ import java.nio.file.Paths private val log = LoggerFactory.getLogger("com.correx.apps.server.Main") +/** Per-ping timeout (ms) for the llama-server health probe — short so a hung server never stalls it. */ +private const val LLAMA_HEALTH_TIMEOUT_MS = 3_000L + fun main() { log.info("=== correx server starting ===") log.info(" port : 8080") @@ -438,9 +441,21 @@ fun main() { val seeded = DefaultEventReplayer(eventStore, com.correx.apps.server.health.HealthProjection()) .rebuild(com.correx.apps.server.health.SYSTEM_SESSION) .subjects.mapValues { it.value.status } + // Monitor the llama server only when a llamacpp provider URL is configured — otherwise the + // LLAMA_SERVER subject would report a spurious DEGRADED on a box that runs no local model. + // A dedicated short-timeout client keeps a hung server from stalling the probe loop. + val llamaProbe = correxConfig.providers.firstOrNull { it.type == "llamacpp" }?.url?.let { url -> + val healthHttpClient = io.ktor.client.HttpClient(io.ktor.client.engine.cio.CIO) { + engine { requestTimeout = LLAMA_HEALTH_TIMEOUT_MS } + } + Runtime.getRuntime().addShutdownHook(Thread { healthHttpClient.close() }) + com.correx.apps.server.health.LlamaServerHealthProbe( + com.correx.apps.server.health.HttpLlamaLivenessClient(healthHttpClient, url), + ) + } com.correx.apps.server.health.HealthMonitor( eventStore = eventStore, - probes = listOf( + probes = listOfNotNull( com.correx.apps.server.health.DiskWatermarkProbe( monitoredPaths = listOf(dataDir), warnBytes = hc.diskWarnBytes, @@ -450,9 +465,7 @@ fun main() { eventStore, hc.eventStoreWarnLatencyMs, ), - // TODO(wiring): register LlamaServerHealthProbe once an HttpClient + tokens/sec telemetry are - // exposed to the health-monitor scope (currently only built inside - // InfrastructureModule.createModelManager). + llamaProbe, ), intervalMs = hc.intervalMs, initialStatuses = seeded, From dbc677a5f1541ddfc82b5651f686f16404285bec Mon Sep 17 00:00:00 2001 From: kami Date: Sun, 21 Jun 2026 08:26:19 +0000 Subject: [PATCH 033/107] docs(backlog): record llama-probe registration (64a90e7) + event-store probe in RETRO MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A§4 LLAMA_SERVER probe is now registered (gated on a llamacpp provider URL); EVENT_STORE latency probe was already wired (266bbf0). Both move to RETRO. Remaining A§4: tokens/sec throughput feed (health endpoint is reachability-only) + live health TUI pane. Co-Authored-By: Claude Opus 4.8 --- BACKLOG.md | 11 +++++++---- RETRO.md | 3 ++- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/BACKLOG.md b/BACKLOG.md index 19f1731b..af2be08b 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -28,7 +28,7 @@ matching bullets below are superseded; only the noted follow-ups remain live: - **G** turnId `/repo`↔`/repo2` collision + WorkspaceStateProbe newline fingerprint — `784accb` - **I** `.cs`/Godot-Mono symbols — `f715ffa`; structural `.tscn`/`.tres` validator — `0e251d6` -- **A §4** EventStore latency probe (wired) + llama-server probe — `266bbf0` *(follow-up: llama probe live wiring)* +- **A §4** EventStore latency probe (wired) + llama-server probe — `266bbf0`, **registered** `64a90e7` (gated on a configured `llamacpp` provider URL) - **B §6 + C-A3** `CritiqueFinding` type + calibration projection — `f783eed` *(follow-up: `CritiqueOutcomeCorrelatedEvent` producer)* - **C-A1** brief echo-back gate — `1df7af5` *(follow-up: `planner.md` brief_echo + `role_pipeline` metadata to activate)* - **C-A2** stage-level plan checkpointing — `1a1b5cc` @@ -51,9 +51,12 @@ matching bullets below are superseded; only the noted follow-ups remain live: Metrics + CLI + TUI pane and the health backbone are shipped (fe94140, 4f6bfa8, f8fd260) — those are RETRO candidates once live-QA'd. Remaining: -- [ ] **§4 LLAMA_SERVER health probe** — liveness + tokens/sec degradation trend - (session-scoped, needs HTTP). Same `HealthProbe` framework as the shipped disk probe. -- [ ] **§4 EVENT_STORE health probe** — append + fsync latency heartbeat. +- [x] **§4 LLAMA_SERVER health probe** — `266bbf0` (probe) + `64a90e7` (registered in the health + monitor, gated on a `llamacpp` provider URL, via `HttpLlamaLivenessClient`). Liveness is live; + tokens/sec degradation needs a throughput feed (the `/health` endpoint is reachability-only) — + that remainder noted in RETRO. → RETRO. +- [x] **§4 EVENT_STORE health probe** — append + fsync latency heartbeat. `266bbf0` + (`EventStoreLatencyProbe.forStore`, wired into the health monitor). → RETRO. - [ ] **§4/§3 live health TUI pane** — deferred to follow the metrics cadence (projection + CLI first, then pane). `correx health` CLI already exists. - [ ] **§3 tier-2 stats** — full session-completion summary pane polish (basic pane shipped). diff --git a/RETRO.md b/RETRO.md index 31336a8f..24e2ee33 100644 --- a/RETRO.md +++ b/RETRO.md @@ -35,12 +35,13 @@ Nine tracks, each compiled + unit-tested green before commit. | `4b0024f` | Batch source-list fetch-approval: `POST /sessions/{id}/approve-sources` → `SourceListHosts.extract` (java.net.URI, lower-cased, ports stripped, dossier-line tolerant) → one `EgressHostsGrantedEvent`; live allowlist then auto-clears those web_fetches (no per-URL T2) | D | | `f107ff5` | Idea-board → project-profile promotion: `IdeaPromotedEvent` (board tombstone, like discard) + `ProjectProfileWriter` (escape-aware TOML serialize/write) + `ProjectProfile.withConvention` (deduped) + server `PromoteIdea` handler (loads profile, appends idea text, writes `.correx/project.toml`); also made `SimpleToml`'s reader unescape `\\`/`\"` symmetrically with the writers (fixes pre-existing read/write asymmetry) | E | | `4e08510` | Architect contradiction-check **activation**: `ServerModule.start()` subscribes to the architect stage's `design` `ArtifactCreatedEvent`, resolves decision text (design-schema `approach` field, capped fallback), runs `ArchitectContradictionChecker.check`, appends the non-null `PossibleContradictionFlaggedEvent`. Live-only, never halts. Checker built in `Main.kt` (gated on `project.enabled`) and threaded into `ServerModule`. Replaces the `Main.kt` `TODO(wiring)` | B §4 (activation) | +| `64a90e7` | Llama-probe **registration**: `LlamaServerHealthProbe` (266bbf0) wired into the health monitor when a `llamacpp` provider URL is configured — dedicated CIO client (3s timeout, shutdown-hook close) + `HttpLlamaLivenessClient`. Gated so a model-less box reports no spurious `LLAMA_SERVER` DEGRADED. Replaces the `Main.kt` `TODO(wiring)` | A §4 (activation) | ### Follow-ups created this pass (refiled into BACKLOG) - **C-A1 activation** — `planner.md` must emit a `brief_echo` block and `role_pipeline.toml` must set `briefEcho="true"` / `briefEchoSource` for the gate to fire live (model-dependent → live-QA). - **C-A3 producer** — nothing emits `CritiqueOutcomeCorrelatedEvent` yet; the calibration projection has no live feed until a reviewer-loop producer records finding outcomes. - **B §4 wiring** — ✅ DONE (`4e08510`): `ServerModule.start()` now subscribes to the architect `design` artifact and emits the flag. (In-session L3 embedding is NOT needed — the checker filters to PRIOR sessions by design, so it works against prior runs' distilled decisions.) -- **A §4 llama wiring** — `LlamaServerHealthProbe` is unregistered: an `HttpClient` + tokens/sec telemetry aren't exposed to the health-monitor scope yet (`// TODO(wiring)` in `Main.kt`). +- **A §4 llama wiring** — ✅ DONE (`64a90e7`): the probe is registered in the health monitor (gated on a configured `llamacpp` provider URL) via `HttpLlamaLivenessClient`. (tokens/sec telemetry still rides liveness-only — the `/health` endpoint reports reachability; a throughput feed remains future work but the spec's liveness probe is live.) - **D egress wiring** — ✅ DONE (`ab7e4be`): session egress state is now threaded into `ToolCallAssessmentInput` at `runPlane2Assessment`, so `NetworkHostRule` consults the live union. Batch fetch-approval ✅ DONE (`4b0024f`): `POST /sessions/{id}/approve-sources` emits one `EgressHostsGrantedEvent` for the whole source list. - **B §5 static_check stage** — runner/event/filter complete and the filter is live, but no workflow stage emits `StaticFindingsRecordedEvent` yet (`// TODO(wiring)` in `StaticAnalysisRunner.kt`); needs an event-emitting deterministic stage-type seam. - **E toolchain** — tui-go now builds via auto-downloaded go1.24.2 (`GOTOOLCHAIN=auto`). The committed directive fix assumes 1.26 was unintended; bump if a specific newer release is actually wanted. From 9eef93607321d9aaa5c4e9ea48d4ac5368af13a1 Mon Sep 17 00:00:00 2001 From: kami Date: Sun, 21 Jun 2026 08:36:11 +0000 Subject: [PATCH 034/107] =?UTF-8?q?fix(health):=20llama=20probe=20also=20c?= =?UTF-8?q?overs=20the=20managed=20[[models]]=20path=20(A=C2=A74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 64a90e7 gated the probe on a static [[providers]] llamacpp url, missing the primary managed path (sample-config.toml's [[models]], where correx spawns llama-server at [models].host:port and registers no provider entry). Fall back to that host:port when [[models]] is configured so the LLAMA_SERVER subject is monitored on the common setup. Surfaced while drafting the A§4 live-QA plan. Compile green. Co-Authored-By: Claude Opus 4.8 --- .../src/main/kotlin/com/correx/apps/server/Main.kt | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt index 97ba6a80..e65e3b87 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt @@ -441,10 +441,15 @@ fun main() { val seeded = DefaultEventReplayer(eventStore, com.correx.apps.server.health.HealthProjection()) .rebuild(com.correx.apps.server.health.SYSTEM_SESSION) .subjects.mapValues { it.value.status } - // Monitor the llama server only when a llamacpp provider URL is configured — otherwise the - // LLAMA_SERVER subject would report a spurious DEGRADED on a box that runs no local model. + // Monitor the llama server only when one is configured — otherwise the LLAMA_SERVER subject + // would report a spurious DEGRADED on a box that runs no local model. Covers BOTH paths: an + // explicit static [[providers]] url, or the managed [[models]] server at [models].host:port. // A dedicated short-timeout client keeps a hung server from stalling the probe loop. - val llamaProbe = correxConfig.providers.firstOrNull { it.type == "llamacpp" }?.url?.let { url -> + val llamaUrl = correxConfig.providers.firstOrNull { it.type == "llamacpp" }?.url + ?: correxConfig.models.firstOrNull()?.let { + "http://${correxConfig.modelsSettings.host}:${correxConfig.modelsSettings.port}" + } + val llamaProbe = llamaUrl?.let { url -> val healthHttpClient = io.ktor.client.HttpClient(io.ktor.client.engine.cio.CIO) { engine { requestTimeout = LLAMA_HEALTH_TIMEOUT_MS } } From 12ff2e97b0b8cf063bd48baff43f964de95b31a4 Mon Sep 17 00:00:00 2001 From: kami Date: Sun, 21 Jun 2026 09:14:36 +0000 Subject: [PATCH 035/107] docs(qa): live-QA plans + env bring-up for the burndown features MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six QA plans (docs/qa/QA-*.md) pinning observable evidence for the shipped tracks: research egress + batch source-approval (D), architect contradiction (B§4), llama health probe (A§4), idea promotion (E), reviewer static-first filter (B§5, partial), and the brief-echo gate ARM-IT plan (C-A1) — the latter is the go/no-go for arming the gate in production (it breaks the planner if the model can't emit a parseable brief_echo). Plus the env: docs/qa/ENV.md runbook + docs/qa/README.md index, and scripts/qa/ (sync-config.sh, searxng-up/down.sh with the JSON-format gotcha, README). Scripts are set -euo pipefail, syntax-checked, executable. Authored from the repo build/config (server :apps:server:run, CLI :apps:cli:run, tui-go GOTOOLCHAIN=auto, sample-config.toml). Co-Authored-By: Claude Opus 4.8 --- docs/qa/ENV.md | 126 ++++++++++++++++++++++++++ docs/qa/QA-architect-contradiction.md | 42 +++++++++ docs/qa/QA-brief-echo-gate.md | 52 +++++++++++ docs/qa/QA-idea-promotion.md | 44 +++++++++ docs/qa/QA-llama-health-probe.md | 41 +++++++++ docs/qa/QA-research-egress.md | 42 +++++++++ docs/qa/QA-reviewer-static-first.md | 44 +++++++++ docs/qa/README.md | 33 +++++++ 8 files changed, 424 insertions(+) create mode 100644 docs/qa/ENV.md create mode 100644 docs/qa/QA-architect-contradiction.md create mode 100644 docs/qa/QA-brief-echo-gate.md create mode 100644 docs/qa/QA-idea-promotion.md create mode 100644 docs/qa/QA-llama-health-probe.md create mode 100644 docs/qa/QA-research-egress.md create mode 100644 docs/qa/QA-reviewer-static-first.md create mode 100644 docs/qa/README.md diff --git a/docs/qa/ENV.md b/docs/qa/ENV.md new file mode 100644 index 00000000..eb87b882 --- /dev/null +++ b/docs/qa/ENV.md @@ -0,0 +1,126 @@ +# Correx live-QA environment — bring-up runbook + +The plans in `docs/qa/QA-*.md` cite **observable signals** (events, logs, files, TUI +renders). This runbook stands up the stack that produces them. Run it on a capable box +(JDK 21, Go ≥ 1.24, Docker, a GPU + GGUF model for any model-dependent plan). + +Helper scripts live in `scripts/qa/` (see `scripts/qa/README.md`). + +> **Low-RAM note:** a machine-local `~/.gradle/gradle.properties` on the dev box caps the +> Gradle/Kotlin heap (`-Xmx768m`, `parallel=false`) for a 1.9 GB machine. It is **not +> committed**. On a QA box with real RAM, delete it (or ignore it) so builds aren't throttled. + +--- + +## Prerequisites + +- **JDK 21** (`java -version` → 21). +- **Go ≥ 1.24** — the TUI builds via `GOTOOLCHAIN=auto` (go.mod pins `go 1.24.2`); the + toolchain auto-downloads if your `go` is older. +- **Docker** — only for SearXNG (research-workflow QA). +- **llama-server** binary + at least one **GGUF model** — for any model-dependent plan. +- (optional) an **embedder** model/endpoint — required for the architect-contradiction plan + and any L3-retrieval-dependent behavior (`[router.embedder] backend = "llamacpp"`). + +--- + +## 1. Config + +Correx reads `~/.config/correx/config.toml` plus `workflows/`, `prompts/`, `schemas/` +alongside it. Stale copies cause silent bugs (BACKLOG ops note), so **sync from the repo**: + +```bash +scripts/qa/sync-config.sh # copies examples/workflows + prompts + docs/schemas + # and seeds config.toml from docs/sample-config.toml IF ABSENT +``` + +Then edit `~/.config/correx/config.toml` for the plan you're running: + +- **Model** — pick ONE: + - *Managed* (`[[models]]` + `[models]`): correx spawns llama-server at `[models].host:port` + (default `127.0.0.1:10000`) and kills it on shutdown. Simplest. + - *Static* (`[[providers]]`, type `llamacpp`, `url`): you run llama-server yourself. **Use + this for the llama-health plan** so you can stop the server independently of correx. +- **`[project] enabled = true`** — required for project memory + the **architect-contradiction** + plan (it queries the `project:` L3 namespace written at session end). +- **`[router.embedder] backend = "llamacpp"`** (not `noop`) — required wherever embeddings + matter (architect contradiction, L3 retrieval). `noop` makes every distance meaningless. +- **`[tools.research] enabled = true` + `searxng_url`** — for the research/egress plan. +- **`[health] enabled = true`** — for the llama-health plan; note `interval_ms` (probe cadence). + +A `[[artifacts]]` entry must exist for every LLM-emitted artifact kind a workflow `produces` +(schemas under `~/.config/correx/schemas/`). `sync-config.sh` copies the schemas; confirm the +`[[artifacts]]` table in `config.toml` references the kinds your workflow uses. + +## 2. llama-server + +- **Managed:** nothing to start — correx launches it. The health probe pings + `http://<[models].host>:<[models].port>/health`. +- **External/static:** start your llama-server on the `url` host:port; its `/health` must 2xx. + ```bash + llama-server -m ~/models/.gguf --host 127.0.0.1 --port 10000 + ``` + +## 3. SearXNG (research-workflow QA only) + +```bash +scripts/qa/searxng-up.sh # http://localhost:8888, JSON format enabled +# ... run research QA ... +scripts/qa/searxng-down.sh +``` + +> **Gotcha:** SearXNG ships with only the `html` output format; the research `web_search` +> needs the **JSON API**. `searxng-up.sh` writes a `settings.yml` enabling `formats: [html, json]` +> and mounts it. Without that, `web_search` returns nothing and the workflow stalls. + +## 4. Start the server + +```bash +./gradlew :apps:server:run # mainClass com.correx.apps.server.MainKt, listens on :8080 +# or build a runnable dist once and reuse it: +./gradlew :apps:server:installDist +apps/server/build/install/server/bin/server +``` + +> **Gotcha:** do **not** rebuild jars while the server JVM is running — lazy classloading +> breaks (`NoClassDefFoundError`). Stop the server first, rebuild, restart. + +## 5. Start the TUI + +```bash +cd apps/tui-go +GOTOOLCHAIN=auto go build -o correx-tui . +./correx-tui -host localhost -port 8080 # flags default to localhost:8080 +``` + +## 6. Evidence tools (what the plans cite) + +| Source | Command / where | +|--------|-----------------| +| Event log (the truth) | `./gradlew :apps:cli:run --args="events "` or `GET /sessions/{id}/events` | +| Deterministic replay | `./gradlew :apps:cli:run --args="replay "` | +| System health | `./gradlew :apps:cli:run --args="health"` | +| Server logs | stdout of `:apps:server:run` — MDC carries `sessionId` | +| Files on disk | e.g. `/.correx/project.toml` (idea-promotion plan) | +| TUI render | the running `correx-tui` | + +(Once `:apps:cli:installDist` is built, `apps/cli/build/install/cli/bin/cli events ` works too.) + +## Teardown + +1. Stop the server (Ctrl-C) — a managed `[[models]]` llama-server is killed by the shutdown hook. +2. `scripts/qa/searxng-down.sh` if you started it. +3. External llama-server: stop it yourself. + +--- + +## Per-plan env matrix + +| QA plan | model | embedder=llamacpp | SearXNG | `project.enabled` | workspace `.correx/` | +|---------|:-----:|:-----------------:|:-------:|:-----------------:|:--------------------:| +| `QA-research-egress` | yes (tool-calling) | no | **yes** | no | no | +| `QA-architect-contradiction` | yes | **yes** | no | **yes** | repo root, 2 sessions same process | +| `QA-llama-health-probe` | yes (static path to kill) | no | no | no | no | +| `QA-idea-promotion` | router only (or seed event) | no | no | no | **yes** (bound workspace) | +| `QA-reviewer-static-first` | yes | no | no | no | seed `StaticFindingsRecordedEvent` | +| `QA-brief-echo-gate` | yes (the prod candidate) | no | no | no | analyst brief with criteria | diff --git a/docs/qa/QA-architect-contradiction.md b/docs/qa/QA-architect-contradiction.md new file mode 100644 index 00000000..81ec854a --- /dev/null +++ b/docs/qa/QA-architect-contradiction.md @@ -0,0 +1,42 @@ +# QA Plan: architect contradiction-check (display-only) — `4e08510` `eae0a0c` + +**Status:** DRAFT +**Run date / operator:** +**BACKLOG item:** §B-§4 "architect contradiction-check" — checker `eae0a0c`, live server hook `4e08510`. + +--- + +## Preconditions + +- [ ] **server build/branch:** `feat/backlog-burndown`. +- [ ] **llama-server + model:** a model that runs the `role_pipeline` (analyst→architect→…) and emits a `design` artifact with a populated `approach` field (see `docs/schemas/design.json`). +- [ ] **external deps:** none beyond the model. +- [ ] **config synced:** `[project] enabled = true` (the checker queries the `project:` L3 namespace that `ProjectMemoryService.persist` writes); **`[router.embedder] backend = "llamacpp"`** with a real embedder URL — `noop` embeddings make every distance meaningless and the gate never fires. `[router.l3] backend = "in_memory"` is fine for a single run, but **note**: in-memory L3 is process-lifetime, so the two sessions below must run against the **same** server process. +- [ ] **fixtures/seed:** the same workspace/repo root for both sessions (the namespace is `project:`). + +## Acceptance gate (one sentence) + +> When a later session's architect `design` decision is semantically near a **prior** session's recorded decision, a `PossibleContradictionFlaggedEvent` is appended — and it never halts the stage. + +## Checks + +| # | Action | Expected observable evidence | Result | +|---|--------|------------------------------|--------| +| 1 | Run **session A** end-to-end on a repo; architect commits a clear decision (e.g. "use Postgres for persistence"). Let the session **complete** (distillation runs on completion). | Session A completes; `correx events {A}` shows the architect `design` `ArtifactCreatedEvent`. The decision is now distilled into L3 under `project:` (ProjectMemoryService.persist on completion). | | +| 2 | Run **session B** (same server process, same repo) whose architect decision **reverses** A (e.g. "use SQLite / drop Postgres"). | When B's architect `design` artifact is created, `correx events {B}` contains a `PossibleContradictionFlaggedEvent` — `decisionSummary` = B's `approach`, `related[]` lists A's decision with a `score ≥ 0.75` and `source` = the `project:` turnId. | | +| 3 | Confirm non-interference. | Session B's architect stage **still passes** and proceeds to the planner — the flag is display-only (no `WorkflowFailedEvent`, no stage halt around the flag). | | +| 4 | Run **session C** with an architect decision unrelated to A (different domain). | **No** `PossibleContradictionFlaggedEvent` for C (nothing clears the 0.75 threshold). Absence is the evidence. | | +| 5 | Confirm self-exclusion. | The flag in check 2 never lists **B's own** in-flight decision as `related` (checker filters `entry.sessionId != current`). | | + +Evidence sources: `correx events {id}`, server logs (the `[Orchestrator]`/checker warn line on a near hit is informative but the **event** is the gate). + +## Out of scope (explicitly NOT covered this pass) + +- In-session contradiction (two decisions inside one session) — by design the checker filters to PRIOR sessions; not built and not required. +- LLM-judged contradiction — v1 is similarity-only, display-only. +- ADR `alternativesConsidered` shape (BACKLOG marks this deliberately not taken). + +## Disposition + +- **PASS** → move the §B-§4 entry to `RETRO.md` with this run date + the `PossibleContradictionFlaggedEvent` evidence from check 2. Status: PASSED. +- **FAIL** → file numbered findings in `BACKLOG.md`. Most likely failure = embedder is `noop` or the two sessions ran against different processes (in-memory L3 lost) — fix the env, not the code, then re-run. Status: FAILED until green. diff --git a/docs/qa/QA-brief-echo-gate.md b/docs/qa/QA-brief-echo-gate.md new file mode 100644 index 00000000..fe67c5c5 --- /dev/null +++ b/docs/qa/QA-brief-echo-gate.md @@ -0,0 +1,52 @@ +# QA Plan: brief echo-back gate — ARM-IT plan — `1df7af5` + +**Status:** DRAFT +**Run date / operator:** +**BACKLOG item:** §C-A1 "brief echo-back gate" — gate `1df7af5`. **This plan is the precondition for arming the gate in production.** + +--- + +## Why this plan gates the activation + +The gate code (`checkBriefEcho`) is built and unit-tested but **OFF by default** (`briefEcho != "true"`). Arming it is a config/prompt change that is **model-dependent and not safe to flip blind**: once on, the planner stage requires a parseable `brief_echo` block in its produced artifact. If the planner model does **not** emit one, `checkBriefEcho` returns a **retryable Failure** ("must restate the brief as a brief_echo block") — which, on a model that never complies, **exhausts retries and fails the planner stage**, breaking `role_pipeline`. So: prove the model reliably emits a parseable block **here**, on a QA box, before turning it on anywhere real. + +## How to arm it (do this on the QA box only) + +1. **Prompt** — edit `~/.config/correx/prompts/planner.md` (synced from `examples/workflows/prompts/`) to instruct the model to begin its `impl_plan` artifact with a `brief_echo` object restating the brief, exactly: + ```json + {"brief_echo": {"referenced_files": ["..."], "symbols": ["..."], "acceptance_criteria": ["..."]}, "...rest of impl_plan...": ...} + ``` + (Parsed by `BriefEchoExtractor.fromEcho` — root key `brief_echo`, arrays `referenced_files` / `symbols` / `acceptance_criteria`.) +2. **Workflow metadata** — on the **planner** stage in `~/.config/correx/workflows/role_pipeline.toml`, set stage metadata `briefEcho = "true"` and `briefEchoSource = "analysis"` (the analyst's artifact slot is named `analysis`, not the default `brief`). The original brief's referenced files are pulled by `BriefReferenceExtractor`; optional `acceptance_criteria` / `symbols` arrays on the analysis artifact make the comparison meaningful. + +## Preconditions + +- [ ] **server build/branch:** `feat/backlog-burndown`. +- [ ] **llama-server + model:** the production candidate model (this plan is specifically testing *that model's* compliance). +- [ ] **config synced + armed** as above. +- [ ] **fixtures/seed:** an analyst `analysis` artifact that names concrete files + acceptance criteria, so divergence is detectable. + +## Acceptance gate (one sentence) + +> With the gate armed, the planner reliably emits a **parseable** `brief_echo`, the stage **passes** when the echo matches the brief, and **only** diverging echoes produce a `BriefEchoMismatchEvent` + retryable failure — never a "missing block" failure on a compliant run. + +## Checks + +| # | Action | Expected observable evidence | Result | +|---|--------|------------------------------|--------| +| 1 | Run `role_pipeline` to the planner stage with a faithful brief. | The planner's `impl_plan` artifact contains a parseable `brief_echo`; the stage **passes** (proceeds to implementer). **No** `BriefEchoMismatchEvent`, no "must restate the brief" failure. | | +| 2 | Run it **3–5 times** across varied briefs. | The model emits a parseable `brief_echo` **every** time (reliability bar). Tally pass rate — anything < 100% means arming will intermittently break the planner. | | +| 3 | Seed a brief with a clear acceptance criterion, then induce the planner to drop it (or hand-edit the echo to omit it). | A `BriefEchoMismatchEvent` with `droppedAcceptanceCriteria` set; stage Failure (retryable) whose message restates the dropped criterion. | | +| 4 | Induce the echo to reference a file **not** in the brief. | `BriefEchoMismatchEvent.inventedFiles` set; failure message names the invented path. | | +| 5 | Temporarily make the planner emit **no** `brief_echo` block. | Failure "must restate the brief as a brief_echo block before planning" — confirming the strict-on-armed behavior (this is the break mode check 2 must rule out for the real model). | | + +Evidence sources: `correx events {id}` (`BriefEchoMismatchEvent`), the produced `impl_plan` artifact content, server logs (`[Orchestrator] brief echo diverged`). + +## Out of scope + +- Symbol grounding precision (deferred; file-path + acceptance-criteria grounding only). + +## Disposition + +- **PASS (check 2 == 100%)** → it is safe to arm in production; move §C-A1 to `RETRO.md` with the reliability tally. Keep the armed `planner.md` + `role_pipeline.toml` changes (or land them into `examples/` if the repo defaults should ship armed). +- **FAIL (check 2 < 100%, or check 5 fires on a normal run)** → **do NOT arm in production.** File the model's non-compliance as a finding; either improve the prompt and re-run, or leave the gate OFF. Status: FAILED until check 2 is reliably green. diff --git a/docs/qa/QA-idea-promotion.md b/docs/qa/QA-idea-promotion.md new file mode 100644 index 00000000..fd3f3f2b --- /dev/null +++ b/docs/qa/QA-idea-promotion.md @@ -0,0 +1,44 @@ +# QA Plan: idea-board → project-profile promotion — `f107ff5` + +**Status:** DRAFT +**Run date / operator:** +**BACKLOG item:** §E "Idea board → profile promotion". + +--- + +## Preconditions + +- [ ] **server build/branch:** `feat/backlog-burndown`. +- [ ] **llama-server + model:** a router model only if you capture the idea via the rubber-duck path (it must emit a ```` ```json {"ideas":[...]} ``` ```` block). To avoid model-dependence you may instead **seed an `IdeaCapturedEvent`** directly (see note below). +- [ ] **external deps:** none. +- [ ] **config synced:** standard. +- [ ] **fixtures/seed:** a session bound to a **workspace** (a `SessionWorkspaceBoundEvent` must exist for the capturing session — promotion writes into `/.correx/project.toml` and no-ops without a bound workspace). Start the TUI/session from inside the target repo dir so the workspace resolves. + +## Acceptance gate (one sentence) + +> Promoting a captured idea appends its text as a `conventions` entry in `/.correx/project.toml`, records an `IdeaPromotedEvent`, and removes the idea from the board. + +## Checks + +| # | Action | Expected observable evidence | Result | +|---|--------|------------------------------|--------| +| 1 | Capture an idea (rubber-duck `{"ideas":[...]}`, or seed an `IdeaCapturedEvent`). List the board (TUI `R`/idea overlay, or `ListIdeas` WS). | The idea shows on the board with a stable `ideaId`; `IdeaCapturedEvent` in the log. | | +| 2 | Note the pre-state of `/.correx/project.toml` (may be absent). | File absent or its current `conventions = [...]` recorded. | | +| 3 | Send `PromoteIdea(ideaId)` (TUI promote key, or the WS `PromoteIdea` message). | `/.correx/project.toml` now exists and its `conventions` list contains the idea text **verbatim** (escaped if it had quotes). Re-reading via `ProjectProfileLoader` round-trips it (no corruption). | | +| 4 | Re-read the log and the board. | An `IdeaPromotedEvent(ideaId, sessionId, text, timestampMs)` is appended; the idea **no longer appears** on the board (`activeIdeas` tombstones promoted ids like discards). | | +| 5 | Promote the **same** idea text again from a second idea (dedup). | `withConvention` does not duplicate an identical convention — the list has it once. | | +| 6 | Promote with **no bound workspace** (start a session with an unresolved working dir). | No file write, no `IdeaPromotedEvent` — the handler no-ops and just refreshes the board (safe). | | + +> **Seeding without a model:** append an `IdeaCapturedEvent(ideaId, sessionId, text, timestampMs)` for a workspace-bound session via the event store, then drive `PromoteIdea`. This isolates the promotion path from router-prompt behavior. + +Evidence sources: the file `/.correx/project.toml` on disk, `correx events {id}`, the TUI board render. + +## Out of scope (explicitly NOT covered this pass) + +- The rubber-duck capture quality itself (covered by the separate idea-board live-QA, §F). +- Promotion into `about`/`commands` — v1 promotes into `conventions` only. + +## Disposition + +- **PASS** → move the §E "Idea board → profile promotion" bullet to `RETRO.md` with this run date + the on-disk `.correx/project.toml` evidence. Status: PASSED. +- **FAIL** → numbered findings (e.g. quote-escaping corruption, board not clearing). Status: FAILED until green. diff --git a/docs/qa/QA-llama-health-probe.md b/docs/qa/QA-llama-health-probe.md new file mode 100644 index 00000000..a17fcd2e --- /dev/null +++ b/docs/qa/QA-llama-health-probe.md @@ -0,0 +1,41 @@ +# QA Plan: LLAMA_SERVER health probe — `266bbf0` `64a90e7` `9eef936` + +**Status:** DRAFT +**Run date / operator:** +**BACKLOG item:** §A-§4 "LLAMA_SERVER health probe" — probe `266bbf0`, registration `64a90e7`, managed-path coverage `9eef936`. + +--- + +## Preconditions + +- [ ] **server build/branch:** `feat/backlog-burndown`. +- [ ] **llama-server + model:** a real llama-server reachable at the configured URL. The probe registers when **either** a static `[[providers]]` (type `llamacpp`, `url`) **or** a managed `[[models]]` entry exists (managed URL = `[models].host:[models].port`). Verify your config has one. +- [ ] **external deps:** none. +- [ ] **config synced:** `[health] enabled = true`; note `[health] interval_ms` (the probe cadence — you wait one interval to see an edge). +- [ ] **fixtures/seed:** ability to stop/start the llama-server process (managed: it dies with the server; for the kill/restore test use the **static `[[providers]]`** path so you can stop llama-server independently of correx). + +## Acceptance gate (one sentence) + +> The `LLAMA_SERVER` subject is monitored only when a model is configured, reports HEALTHY while the server answers `/health`, and emits a **liveness DEGRADED** edge when the server stops — and a restored edge when it returns. + +## Checks + +| # | Action | Expected observable evidence | Result | +|---|--------|------------------------------|--------| +| 1 | Start correx with a llama-server configured and reachable. Wait one `interval_ms`. | `correx health` (and the health projection) lists `LLAMA_SERVER` as HEALTHY. No spurious DEGRADED. | | +| 2 | **Stop** the llama-server (static-provider path: kill the external process). Wait one `interval_ms`. | A health **degraded** edge event for subject `LLAMA_SERVER`, `metric = "liveness"`, detail "llama server unreachable" appears in the system-health log (`correx events` on the SYSTEM session / `correx health`). | | +| 3 | **Restart** the llama-server. Wait one `interval_ms`. | A **restored** edge event for `LLAMA_SERVER`; `correx health` shows HEALTHY again. Edges are recorded once (edge-triggered, not per-tick). | | +| 4 | Start correx on a box with **no** `[[providers]]` and **no** `[[models]]`. | `LLAMA_SERVER` is **absent** from the health subjects — the probe is not registered (no spurious DEGRADED). Absence is the evidence. | | +| 5 | `correx replay` the SYSTEM health session. | The degraded/restored edges replay deterministically from the log (the rolling window is live-only per Invariant #8 and does not affect replay). | | + +Evidence sources: `correx health` CLI, the health projection events, server logs. + +## Out of scope (explicitly NOT covered this pass) + +- **tokens/sec degradation** — the `/health` endpoint is reachability-only; `HttpLlamaLivenessClient` never populates `tokensPerSecond`, so the throughput-collapse branch of `LlamaServerHealthProbe` is dormant until a tps telemetry feed exists. Liveness only this pass. (Refiled in BACKLOG §A-§4 remainder.) +- The live health **TUI pane** (deferred; system-health events are not yet on the TUI wire). + +## Disposition + +- **PASS** → move the §A-§4 LLAMA_SERVER bullet to `RETRO.md` (liveness verified) with this run date; keep the tokens/sec remainder open in BACKLOG. Status: PASSED. +- **FAIL** → numbered findings. A likely miss: the managed `[[models]]` path not registering — confirm `9eef936` is in the build. Status: FAILED until green. diff --git a/docs/qa/QA-research-egress.md b/docs/qa/QA-research-egress.md new file mode 100644 index 00000000..000932ef --- /dev/null +++ b/docs/qa/QA-research-egress.md @@ -0,0 +1,42 @@ +# QA Plan: research egress allowlist + batch source-list approval — `ab7e4be` `4b0024f` `b098d87` `027ff1f` + +**Status:** DRAFT +**Run date / operator:** +**BACKLOG item:** §D — "Dynamic per-session egress allowlist", "§3 batch fetch-approval at source-list level", "Dedicated `SourceFetched` / `LowQualityExtraction` events", plus the §D "Live-QA the full run" gate. + +--- + +## Preconditions + +- [ ] **server build/branch:** `feat/backlog-burndown` — _rebuild only with the server stopped (lazy classloading breaks otherwise)_ +- [ ] **llama-server + model:** a tool-calling-capable model (the research workflow drives `web_search`/`web_fetch` tool calls). Managed `[[models]]` or static `[[providers]]`. +- [ ] **external deps:** **SearXNG** at `[tools.research] searxng_url` with the JSON format enabled (`scripts/qa/searxng-up.sh`; see `docs/qa/ENV.md` §3). Outbound network for the fetched hosts. +- [ ] **config synced:** `~/.config/correx` matches `examples/*` + `docs/schemas/*` (`scripts/qa/sync-config.sh`); `research.toml` present; `[tools.research] enabled = true`. +- [ ] **fixtures/seed:** a research prompt that yields ≥2 distinct source hosts (e.g. "summarize the latest on citing official docs"). + +## Acceptance gate (one sentence) + +> Approving a source list once clears every subsequent `web_fetch` to those hosts **without a per-URL T2 prompt**, and the fetch quality lands as dedicated `SourceFetched` / `LowQualityExtraction` events — while a host NOT on the list still raises T2. + +## Checks + +| # | Action | Expected observable evidence | Result | +|---|--------|------------------------------|--------| +| 1 | Start a research session; let it reach a state with candidate source URLs. | `web_search` T1 ran; the candidate URLs appear in the session (search result / dossier event). | | +| 2 | `POST /sessions/{id}/approve-sources` with the candidate URL list (2+ hosts). | Exactly **one** `EgressHostsGrantedEvent` in `correx events {id}` — `hosts` = the distinct lower-cased hostnames (ports stripped), `reason = "batch source-list approval"`. | | +| 3 | Let the workflow `web_fetch` each approved URL. | Each fetch to an approved host proceeds with **no `ApprovalRequestedEvent` (T2)** — `NetworkHostRule` PROCEEDs via the per-session union. A `SourceFetchedEvent` (with `content_sha256` + quality) is recorded per successful fetch. | | +| 4 | Force a low-quality extraction (e.g. a near-empty / boilerplate page among the sources). | A `LowQualityExtractionEvent` is recorded (not just metadata on `ToolExecutionCompletedEvent`). | | +| 5 | Have the agent attempt a `web_fetch` to a host **not** in the approved list. | A T2 `ApprovalRequestedEvent` IS raised for that host (the allowlist is a union, not a blanket open). | | +| 6 | `correx replay {id}`. | Replay reproduces the same allowlist decisions deterministically (the grant + fetch events are the only inputs; no re-fetch). | | + +Evidence sources: `correx events {id}` / `GET /sessions/{id}/events`, server logs (MDC sessionId), `correx replay {id}`. + +## Out of scope (explicitly NOT covered this pass) + +- The standalone §D web-approval client (third client) — separate track, not built. +- Headless-browser / LLM-based extraction (non-responsibilities). + +## Disposition + +- **PASS** → move the §D egress/batch/source-event bullets to `RETRO.md` with this run date + the cited `EgressHostsGrantedEvent` / `SourceFetchedEvent` evidence. Status: PASSED. +- **FAIL** → file each failure as a numbered finding in `BACKLOG.md` (action + repro + the wrong/missing signal). Status: FAILED until green. diff --git a/docs/qa/QA-reviewer-static-first.md b/docs/qa/QA-reviewer-static-first.md new file mode 100644 index 00000000..3602f0a7 --- /dev/null +++ b/docs/qa/QA-reviewer-static-first.md @@ -0,0 +1,44 @@ +# QA Plan: reviewer static-first (findings exclusion) — `447fc7a` + +**Status:** DRAFT (PARTIAL — see gap) +**Run date / operator:** +**BACKLOG item:** §B-§5 "reviewer static-first INFRA" — runner + event + context-exclusion `447fc7a`. + +--- + +## Gap up front (read first) + +The exclusion **filter** is live-wired into reviewer context assembly, and `StaticAnalysisRunner` + `StaticFindingsRecordedEvent` are built and unit-tested — but **no stock workflow stage emits `StaticFindingsRecordedEvent` yet** (the deterministic static-check stage-type seam is an open BACKLOG follow-up). So this pass verifies the **filter behavior by injecting the event**; the end-to-end "a stage runs detekt and the reviewer skips those findings" flow is blocked on that seam. + +## Preconditions + +- [ ] **server build/branch:** `feat/backlog-burndown`. +- [ ] **llama-server + model:** a model that runs `review_loop` / `role_pipeline` reviewer stage. +- [ ] **external deps:** none. +- [ ] **config synced:** `review_loop.toml` (or role_pipeline) present. +- [ ] **fixtures/seed:** a way to append a `StaticFindingsRecordedEvent` for the session (manual event-store seed), carrying a finding that matches a compile/lint issue the code actually has. + +## Acceptance gate (one sentence) + +> When a `StaticFindingsRecordedEvent` has already recorded a static finding this session, the reviewer's assembled context **mechanically omits** any feedback entry that merely restates that finding. + +## Checks + +| # | Action | Expected observable evidence | Result | +|---|--------|------------------------------|--------| +| 1 | Seed a `StaticFindingsRecordedEvent` for the session with a known finding (e.g. an unused-import lint at `Foo.kt:12`). | The event is in `correx events {id}`. | | +| 2 | Drive the implementer→reviewer step so reviewer context is assembled. | The reviewer's input context (server log of the assembled prompt / context pack) **does not** contain a feedback entry restating the seeded static finding — `excludeStaticFindingsFromReview` stripped it. | | +| 3 | Seed a finding that does **not** match any retry-feedback entry. | Non-matching entries are untouched; only restatements of recorded static findings are dropped. | | +| 4 | (When the static-check stage seam lands) run a workflow whose static stage emits the event for real. | The reviewer never re-raises the compiler/detekt issues the deterministic tool already caught. _(Deferred — seam not built.)_ | | + +Evidence sources: server logs of the assembled reviewer context / context pack, `correx events {id}`. + +## Out of scope (explicitly NOT covered this pass) + +- The **producing** static-check stage (open BACKLOG §B-§5 follow-up: "a stage that emits `StaticFindingsRecordedEvent`"). Check 4 stays deferred until that seam exists. +- The reviewer prompt half (shipped earlier, `3467826`). + +## Disposition + +- **PASS (partial)** → record in `RETRO.md` that the **filter** is live-verified (checks 1–3) and keep the §B-§5 *producing-stage* follow-up open in BACKLOG. Do not claim the full static-first loop until check 4 passes. +- **FAIL** → numbered findings. Status: FAILED until green. diff --git a/docs/qa/README.md b/docs/qa/README.md new file mode 100644 index 00000000..00829ff8 --- /dev/null +++ b/docs/qa/README.md @@ -0,0 +1,33 @@ +# Correx live-QA plans + +Each `QA-.md` is the artifact that authorizes a `BACKLOG.md` → `RETRO.md` move: +it pins **observable evidence** (events, logs, files, TUI renders) for one shipped feature. +Template: `TEMPLATE.md`. Environment bring-up: **`ENV.md`** (+ `scripts/qa/`). + +A feature is **not** "resolved" until its plan passes live. On PASS → move the BACKLOG entry +to RETRO with the run date + cited evidence; on FAIL → refile each miss as a numbered finding. + +## Plans (this burndown) + +| Plan | Feature | Commits | Needs | +|------|---------|---------|-------| +| `QA-research-egress.md` | per-session egress allowlist + batch source-list approval + dedicated source events | `ab7e4be` `4b0024f` `b098d87` `027ff1f` | model + **SearXNG** | +| `QA-architect-contradiction.md` | architect contradiction-check (display-only) | `4e08510` `eae0a0c` | model + **llamacpp embedder** + `project.enabled`, 2 sessions/1 process | +| `QA-llama-health-probe.md` | LLAMA_SERVER liveness probe | `64a90e7` `9eef936` `266bbf0` | static llama-server you can stop | +| `QA-idea-promotion.md` | idea-board → `.correx/project.toml` promotion | `f107ff5` | bound workspace (router model optional — can seed the event) | +| `QA-reviewer-static-first.md` | reviewer static-findings exclusion (PARTIAL — producing stage not built) | `447fc7a` | seed `StaticFindingsRecordedEvent` | +| `QA-brief-echo-gate.md` | **ARM-IT** plan: brief echo-back gate (off by default; risky to arm blind) | `1df7af5` | prod-candidate model | + +## Order of attack (suggested) + +1. **`QA-llama-health-probe`** + **`QA-idea-promotion`** — cheapest, least model-dependent (health is liveness; promotion can be seeded). Quick wins. +2. **`QA-research-egress`** — once SearXNG is up. +3. **`QA-architect-contradiction`** — needs a real embedder + two sessions. +4. **`QA-brief-echo-gate`** — run this **before** arming the gate anywhere real; check 2 (100% parseable-echo across runs) is the go/no-go for production. +5. **`QA-reviewer-static-first`** — partial until the static-check stage seam lands. + +## Still-open §F live-QA gates (no dedicated plan yet — same env applies) + +These predate the burndown and remain in `BACKLOG.md` §F; author a plan from `TEMPLATE.md` +when you tackle one: Epic 15 (events/replay + MDC sessionIds), idea-board capture, clarification +loop + workflow-propose panel, the full research-workflow run, TUI kernel-steering + AMD gauge. From 7d4e2034292a52e0e80aa87daa335ca3ad8b2977 Mon Sep 17 00:00:00 2001 From: kami Date: Sun, 21 Jun 2026 09:14:56 +0000 Subject: [PATCH 036/107] docs(backlog): point at the drafted live-QA plans (docs/qa) Co-Authored-By: Claude Opus 4.8 --- BACKLOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/BACKLOG.md b/BACKLOG.md index af2be08b..439879e8 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -44,6 +44,11 @@ matching bullets below are superseded; only the noted follow-ups remain live: **§E tui-go remaining (UNBLOCKED — Go via `GOTOOLCHAIN=auto` go1.24.2):** plan comparison view; idea-board→profile promotion. **Still blocked in this sandbox:** F live-QA gates + D §6 web approval client (need local model / SearXNG / GPU). +**📋 Live-QA plans drafted** (`docs/qa/QA-*.md`, index `docs/qa/README.md`, env `docs/qa/ENV.md` + `scripts/qa/`): +research-egress (D), architect-contradiction (B§4), llama-health-probe (A§4), idea-promotion (E), +reviewer-static-first (B§5, partial), and the **brief-echo-gate ARM-IT** plan (C-A1 — run it before +arming the gate; it's the go/no-go). These features stay listed until their plan **passes live**, then move to RETRO. + --- ## A. Observability spec — `docs/plans/2026-06-13-observability-spec.md` From 9143d554f14b559ec1e21db708597f6b9934df55 Mon Sep 17 00:00:00 2001 From: kami Date: Sun, 21 Jun 2026 09:33:16 +0000 Subject: [PATCH 037/107] feat(tui-go): global input history + stop idle redraws (copy-safe) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Input history is now a single global ring (recordInput) shared by every input surface — free-chat, the workflow-intent line, and in-session chat — so ↑/↓ recalls anything you've sent, including outside a session (previously per-session, so idle/intent had none). Copy fix: the 120ms animation tick now only runs while something actually animates (animating(): active session spinner, blinking caret, reconnect). Init no longer starts it unconditionally; tickKick (re)starts it on demand and tickMsg stops it when idle. An idle screen no longer auto-redraws, so a native terminal text selection survives instead of being wiped every frame. Tests cover the ring (dedup/cap/cross-context walk) and the idle gate. Co-Authored-By: Claude Opus 4.8 --- .../tui-go/internal/app/input_history_test.go | 91 +++++++++++++++++++ apps/tui-go/internal/app/model.go | 12 ++- apps/tui-go/internal/app/update.go | 89 +++++++++++++++--- 3 files changed, 173 insertions(+), 19 deletions(-) create mode 100644 apps/tui-go/internal/app/input_history_test.go diff --git a/apps/tui-go/internal/app/input_history_test.go b/apps/tui-go/internal/app/input_history_test.go new file mode 100644 index 00000000..3b741567 --- /dev/null +++ b/apps/tui-go/internal/app/input_history_test.go @@ -0,0 +1,91 @@ +package app + +import "testing" + +// History is a single global ring shared by every input surface (free-chat, the +// workflow-intent line, in-session chat), so ↑/↓ recalls anything you've sent — including +// outside a session, which the per-session map could not do. +func TestRecordInputGlobalRing(t *testing.T) { + m := NewModel(nil) + + m.recordInput("first") + m.recordInput(" second ") // trimmed + m.recordInput("second") // immediate dup ignored + m.recordInput("") // blank ignored + m.recordInput(" ") // blank-after-trim ignored + + want := []string{"first", "second"} + if len(m.inputHistory) != len(want) { + t.Fatalf("inputHistory = %q, want %q", m.inputHistory, want) + } + for i := range want { + if m.inputHistory[i] != want[i] { + t.Fatalf("inputHistory[%d] = %q, want %q", i, m.inputHistory[i], want[i]) + } + } +} + +func TestRecordInputCaps(t *testing.T) { + m := NewModel(nil) + for i := 0; i < 250; i++ { + m.recordInput(string(rune('a'+i%26)) + itoa(i)) + } + if len(m.inputHistory) != 100 { + t.Fatalf("ring len = %d, want 100 (capped)", len(m.inputHistory)) + } +} + +// (itoa is defined in view.go and reused here for the cap test.) + +// ↑/↓ walks the ring with no session selected (selectedID == ""), proving history +// recall works in free-chat / intent contexts, not just in-session. +func TestHistoryWalkWithoutSession(t *testing.T) { + m := NewModel(nil) + m.recordInput("one") + m.recordInput("two") + m.inputBuffer = "draft" + + m.historyPrev() // newest first + if m.inputBuffer != "two" { + t.Fatalf("prev#1 = %q, want %q", m.inputBuffer, "two") + } + m.historyPrev() + if m.inputBuffer != "one" { + t.Fatalf("prev#2 = %q, want %q", m.inputBuffer, "one") + } + m.historyPrev() // clamp at oldest + if m.inputBuffer != "one" { + t.Fatalf("prev#3 (clamp) = %q, want %q", m.inputBuffer, "one") + } + m.historyNext() + if m.inputBuffer != "two" { + t.Fatalf("next#1 = %q, want %q", m.inputBuffer, "two") + } + m.historyNext() // back to the stashed live draft + if m.inputBuffer != "draft" { + t.Fatalf("next#2 = %q, want restored draft %q", m.inputBuffer, "draft") + } +} + +// (animating + caps tests below.) + +// animating() gates the redraw tick: it must be FALSE on an idle screen (normal mode, +// no active session) so terminal selection survives, and TRUE while typing or spinning. +func TestAnimatingGatesIdleRedraw(t *testing.T) { + m := NewModel(nil) + m.editMode = ModeNormal + if m.animating() { + t.Fatal("idle (normal mode, no active session) must NOT animate — else selection is wiped") + } + + m.editMode = ModeInsert + if !m.animating() { + t.Fatal("insert mode must animate (blinking caret)") + } + m.editMode = ModeNormal + + m.sessions = []Session{{ID: "s1", Active: true}} + if !m.animating() { + t.Fatal("an active session must animate (spinner)") + } +} diff --git a/apps/tui-go/internal/app/model.go b/apps/tui-go/internal/app/model.go index 6ac6168f..870c72d8 100644 --- a/apps/tui-go/internal/app/model.go +++ b/apps/tui-go/internal/app/model.go @@ -228,9 +228,9 @@ type Model struct { inputMode InputMode inputBuffer string inputCursor int - history map[string][]string - historyIndex int - savedBuffer string + inputHistory []string // submitted lines across all contexts (chat, intent, in-session), newest last + historyIndex int // -1 = editing the live buffer; else an index into inputHistory + savedBuffer string // live buffer stashed while walking history // flow flags sessionEntered bool @@ -302,7 +302,10 @@ type Model struct { paletteIndex int // animation - frame int // tick counter; drives spinner + caret blink + frame int // tick counter; drives spinner + caret blink + ticking bool // true while a tick loop is scheduled. The loop is gated on animating() + // so an idle screen stops redrawing — which lets native terminal selection survive + // (the 120ms redraw used to wipe a mouse drag every frame). // snapshot phase snapshotPhase bool @@ -338,7 +341,6 @@ func NewModel(client *ws.Client) Model { theme: NewTheme(SoftBlue), wfIndex: -1, inputMode: ModeRouter, - history: map[string][]string{}, historyIndex: -1, routerMessages: map[string][]RouterEntry{}, configStaged: map[string]string{}, diff --git a/apps/tui-go/internal/app/update.go b/apps/tui-go/internal/app/update.go index b26687a3..5dc6e10f 100644 --- a/apps/tui-go/internal/app/update.go +++ b/apps/tui-go/internal/app/update.go @@ -27,9 +27,45 @@ func tick() tea.Cmd { return tea.Tick(120*time.Millisecond, func(time.Time) tea.Msg { return tickMsg{} }) } -// Init starts the channel readers and the cursor-blink tick. +// Init starts the channel readers. The animation tick is NOT started here — it is +// kicked lazily by tickKick once something actually needs to animate (an active +// session's spinner, a blinking caret), so an idle screen never auto-redraws. func (m Model) Init() tea.Cmd { - return tea.Batch(readServer(m.client), readConn(m.client), tick()) + return tea.Batch(readServer(m.client), readConn(m.client)) +} + +// animating reports whether anything on screen needs the frame counter to advance: +// a spinning session, a blinking caret (any typing surface), or the reconnect state. +// When false, the tick loop stops and the screen holds still — so a native terminal +// text selection survives instead of being wiped by the next redraw. +func (m Model) animating() bool { + if m.editMode == ModeInsert || m.steering || m.clarTyping || m.proposeTyping || m.configEditing { + return true + } + if m.reconnecting { + return true + } + if m.overlay == OverlayPalette { // the palette shows a blinking filter caret + return true + } + for i := range m.sessions { + if m.sessions[i].Active { + return true + } + } + return false +} + +// tickKick starts the tick loop iff animation is needed and no loop is already +// running. Returns nil otherwise. The single-loop invariant: ticking is true exactly +// while a loop is scheduled (set here, cleared by tickMsg when it stops), so this +// never spawns a second concurrent loop. +func (m *Model) tickKick() tea.Cmd { + if m.animating() && !m.ticking { + m.ticking = true + return tick() + } + return nil } // Update is the single reducer. View renders purely from the returned model. @@ -41,15 +77,19 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tickMsg: m.frame++ - return m, tick() + if m.animating() { + return m, tick() + } + m.ticking = false // loop stops; tickKick will restart it when animation resumes + return m, nil case connMsg: m.applyConn(msg.s) - return m, readConn(m.client) + return m, tea.Batch(readConn(m.client), m.tickKick()) case serverMsg: m.applyServerPhased(msg.m) - return m, readServer(m.client) + return m, tea.Batch(readServer(m.client), m.tickKick()) case sessionsLoadedMsg: m.applySessionsLoaded(msg) @@ -62,7 +102,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case tea.KeyMsg: - return m.handleKey(msg) + next, cmd := m.handleKey(msg) + // A keypress may have entered a typing/active state (insert mode, steering, …); + // kick the tick so the caret/spinner animates again. + if nm, ok := next.(Model); ok { + return nm, tea.Batch(cmd, nm.tickKick()) + } + return next, cmd } return m, nil } @@ -757,6 +803,7 @@ func (m Model) submit() (tea.Model, tea.Cmd) { // Intent line for a workflow being started. if m.wfPendingID != "" { id := m.wfPendingID + m.recordInput(text) m.client.Send(protocol.StartSession(id, text)) m.wfPendingID = "" m.wfPendingName = "" @@ -788,6 +835,7 @@ func (m Model) submit() (tea.Model, tea.Cmd) { s.Name = "chat" m.selectedID = id m.sessionEntered = true + m.recordInput(text) m.client.Send(protocol.StartChatSession(id, text)) m.clearInput() return m, nil @@ -797,12 +845,7 @@ func (m Model) submit() (tea.Model, tea.Cmd) { return m, nil } sid := m.selectedID - hist := m.history[sid] - hist = append(hist, text) - if len(hist) > 50 { - hist = hist[len(hist)-50:] - } - m.history[sid] = hist + m.recordInput(text) // No optimistic echo — the USER turn arrives as a chat.turn event. m.client.Send(protocol.ChatInput(sid, text, m.chatMode)) m.clearInput() @@ -907,8 +950,26 @@ func (m *Model) listNav(dir int) { m.wfIndex = -1 } +// recordInput appends a submitted line to the global history ring (shared across +// free-chat, the workflow-intent line, and in-session chat), deduping the immediate +// repeat and capping the ring. Called from every submit path so ↑/↓ recalls anything +// you've sent, in any input context. +func (m *Model) recordInput(text string) { + text = strings.TrimSpace(text) + if text == "" { + return + } + if n := len(m.inputHistory); n > 0 && m.inputHistory[n-1] == text { + return + } + m.inputHistory = append(m.inputHistory, text) + if len(m.inputHistory) > 100 { + m.inputHistory = m.inputHistory[len(m.inputHistory)-100:] + } +} + func (m *Model) historyPrev() { - hist := m.history[m.selectedID] + hist := m.inputHistory if len(hist) == 0 { return } @@ -926,7 +987,7 @@ func (m *Model) historyPrev() { } func (m *Model) historyNext() { - hist := m.history[m.selectedID] + hist := m.inputHistory switch { case m.historyIndex == -1: return From 85bfddf1c44e72892275c05a4ad063ab9fa3aa2f Mon Sep 17 00:00:00 2001 From: kami Date: Sun, 21 Jun 2026 09:35:34 +0000 Subject: [PATCH 038/107] feat(tui-go): slash command menu in the chat input Typing `/` as the first char of a chat line opens the command palette inline (opencode-style), instead of typing a literal slash; mid-line `/` stays literal. Factors openPalette() (shared with the `p` key) and advertises `/ cmds` in the insert-mode footer when the line is empty. Co-Authored-By: Claude Opus 4.8 --- apps/tui-go/internal/app/slash_test.go | 45 ++++++++++++++++++++++++++ apps/tui-go/internal/app/update.go | 20 ++++++++++-- apps/tui-go/internal/app/view.go | 4 +++ 3 files changed, 66 insertions(+), 3 deletions(-) create mode 100644 apps/tui-go/internal/app/slash_test.go diff --git a/apps/tui-go/internal/app/slash_test.go b/apps/tui-go/internal/app/slash_test.go new file mode 100644 index 00000000..fa7078e1 --- /dev/null +++ b/apps/tui-go/internal/app/slash_test.go @@ -0,0 +1,45 @@ +package app + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" +) + +// `/` as the first char of a chat line opens the command palette inline (opencode-style), +// without typing a literal slash. +func TestSlashOpensPaletteOnEmptyLine(t *testing.T) { + m := NewModel(nil) + m.editMode = ModeInsert + m.inputMode = ModeRouter + m.inputBuffer = "" + + next, _ := m.handleInsertKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}) + nm := next.(Model) + + if nm.overlay != OverlayPalette { + t.Fatalf("'/' on an empty line should open the palette; overlay=%d", nm.overlay) + } + if nm.inputBuffer != "" { + t.Fatalf("'/' should not be typed literally; buffer=%q", nm.inputBuffer) + } +} + +// Mid-line `/` is a literal slash (e.g. a path), not a command trigger. +func TestSlashLiteralMidLine(t *testing.T) { + m := NewModel(nil) + m.editMode = ModeInsert + m.inputMode = ModeRouter + m.inputBuffer = "path" + m.inputCursor = len("path") + + next, _ := m.handleInsertKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}) + nm := next.(Model) + + if nm.overlay != OverlayNone { + t.Fatalf("mid-line '/' must stay literal, not open the palette; overlay=%d", nm.overlay) + } + if nm.inputBuffer != "path/" { + t.Fatalf("mid-line '/' should be typed; buffer=%q", nm.inputBuffer) + } +} diff --git a/apps/tui-go/internal/app/update.go b/apps/tui-go/internal/app/update.go index 5dc6e10f..d2c4c415 100644 --- a/apps/tui-go/internal/app/update.go +++ b/apps/tui-go/internal/app/update.go @@ -229,9 +229,7 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { case "k": m.navUp() case "p": - m.overlay = OverlayPalette - m.paletteFilter = "" - m.paletteIndex = 0 + m.openPalette() case "e": m.overlay = OverlayEventInspector m.overlayEventIdx = 0 @@ -408,12 +406,28 @@ func (m Model) handleInsertKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { m.historyNext() } case tea.KeyRunes, tea.KeySpace: + // opencode-style slash commands: `/` as the first char of a chat line opens the + // command palette inline instead of typing a literal slash. Mid-line `/` is literal. + if m.inputMode == ModeRouter && m.inputBuffer == "" && string(k.Runes) == "/" { + m.editMode = ModeNormal + m.clearInput() + m.openPalette() + return m, nil + } m.appendRunes(string(k.Runes)) m.syncFilter() } return m, nil } +// openPalette opens the command palette with a cleared filter. Shared by the `p` +// normal-mode key and the inline `/` trigger in the chat input. +func (m *Model) openPalette() { + m.overlay = OverlayPalette + m.paletteFilter = "" + m.paletteIndex = 0 +} + // beginIntent stashes the chosen workflow and switches to a text-entry prompt for the // freeform request. Submitting sends StartSession(id, intent); an empty line starts with no // intent (fixed-task workflows). Esc cancels. diff --git a/apps/tui-go/internal/app/view.go b/apps/tui-go/internal/app/view.go index b48eb377..1c4b4451 100644 --- a/apps/tui-go/internal/app/view.go +++ b/apps/tui-go/internal/app/view.go @@ -221,6 +221,10 @@ func (m Model) renderFooter() string { switch { case m.editMode == ModeInsert: hints = []string{hint("enter", "send"), hint("esc", "normal"), hint("↑↓", "history")} + // The `/` command menu only triggers on an empty chat line (mid-line `/` is literal). + if m.inputMode == ModeRouter && m.inputBuffer == "" { + hints = append(hints, hint("/", "cmds")) + } case m.displayState() == StateApproval: // Approval actions live in the band itself; the footer offers navigation only. hints = []string{hint("l", "back"), hint("e", "events"), hint("q", "quit")} From 26621567ac8a8af289e7106e0b86ccb83052ec17 Mon Sep 17 00:00:00 2001 From: kami Date: Sun, 21 Jun 2026 09:40:52 +0000 Subject: [PATCH 039/107] feat(tui-go): transparent modal backdrops Modals now composite over a dimmed copy of the screen behind them instead of an opaque scrim, so the transcript stays faintly visible around a modal (opencode-style). center() stashes the frame's base (View sets m.lastBase), strips+dims it to a faint scrim, and splices the modal box over it (ANSI-aware via ansi.Truncate/TruncateLeft so the side margins keep the dimmed content). An idempotence guard passes an already-full-screen modal through unchanged, so the existing double-center builders don't get re-dimmed. Geometry tests assert the composite still fills the screen exactly. Co-Authored-By: Claude Opus 4.8 --- apps/tui-go/internal/app/model.go | 5 ++ .../internal/app/overlay_composite_test.go | 52 +++++++++++++ apps/tui-go/internal/app/overlays.go | 73 +++++++++++++++++-- apps/tui-go/internal/app/view.go | 2 + 4 files changed, 126 insertions(+), 6 deletions(-) create mode 100644 apps/tui-go/internal/app/overlay_composite_test.go diff --git a/apps/tui-go/internal/app/model.go b/apps/tui-go/internal/app/model.go index 870c72d8..edb7bd73 100644 --- a/apps/tui-go/internal/app/model.go +++ b/apps/tui-go/internal/app/model.go @@ -311,6 +311,11 @@ type Model struct { snapshotPhase bool pendingEvents []protocol.ServerMessage + // lastBase is the full-screen render behind the active modal, stashed by View each + // frame so center() can composite a modal over a dimmed copy of it (transparent + // backdrop) instead of an opaque scrim. Transient render scratch — not real state. + lastBase string + // approval steering input buffer steerBuffer string steering bool diff --git a/apps/tui-go/internal/app/overlay_composite_test.go b/apps/tui-go/internal/app/overlay_composite_test.go new file mode 100644 index 00000000..6b9a687a --- /dev/null +++ b/apps/tui-go/internal/app/overlay_composite_test.go @@ -0,0 +1,52 @@ +package app + +import ( + "strings" + "testing" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" +) + +func fullScreen(ch string, w, h int) string { + row := strings.Repeat(ch, w) + rows := make([]string, h) + for i := range rows { + rows[i] = row + } + return strings.Join(rows, "\n") +} + +// A composited modal must still fill the whole screen exactly (h lines, each w cols), +// so the alt-screen never shows a torn/short region around a "transparent" modal. +func TestCompositeOverlayGeometry(t *testing.T) { + m := NewModel(nil) + m.width, m.height = 40, 12 + m.lastBase = fullScreen("x", 40, 12) + + modal := lipgloss.NewStyle().Width(10).Border(lipgloss.RoundedBorder()).Render("hi\nthere") + out := m.center(modal) + + lines := strings.Split(out, "\n") + if len(lines) != 12 { + t.Fatalf("height = %d, want 12", len(lines)) + } + for i, ln := range lines { + if w := ansi.StringWidth(ln); w != 40 { + t.Fatalf("line %d visible width = %d, want 40", i, w) + } + } +} + +// The idempotence guard: a modal that is already full-screen (a builder that centered +// internally, then got re-centered) must pass through unchanged — not get re-dimmed. +func TestCenterIdempotentOnFullScreen(t *testing.T) { + m := NewModel(nil) + m.width, m.height = 30, 8 + m.lastBase = fullScreen("y", 30, 8) + + full := fullScreen("M", 30, 8) + if got := m.center(full); got != full { + t.Fatal("center should pass a full-screen modal through unchanged") + } +} diff --git a/apps/tui-go/internal/app/overlays.go b/apps/tui-go/internal/app/overlays.go index 3b52600b..fe5370d4 100644 --- a/apps/tui-go/internal/app/overlays.go +++ b/apps/tui-go/internal/app/overlays.go @@ -9,13 +9,74 @@ import ( "github.com/charmbracelet/x/ansi" ) -// center composites a modal over a scrim-filled screen. Immediate-mode: the modal -// is recomputed from state every frame, so there is no separate show/restore path -// to desync (the "diff won't come back" bug class can't occur here). +// center draws a modal centered over a *dimmed* copy of the screen behind it, so the +// transcript stays faintly visible around the modal (a transparent backdrop) rather +// than being hidden by an opaque scrim. Immediate-mode: recomputed from state every +// frame, so there is no show/restore path to desync. +// +// Idempotence guard: some modal builders call center() internally and then get centered +// again by renderOverlay. The inner call already produced a full-screen composite, so a +// second call (input already width×height) returns it unchanged instead of dimming the +// modal itself. Falls back to an opaque scrim when no base was stashed (defensive). func (m Model) center(modal string) string { - return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, modal, - lipgloss.WithWhitespaceBackground(m.theme.P.BgDeep), - lipgloss.WithWhitespaceForeground(m.theme.P.BgDeep)) + if m.lastBase == "" { + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, modal, + lipgloss.WithWhitespaceBackground(m.theme.P.BgDeep), + lipgloss.WithWhitespaceForeground(m.theme.P.BgDeep)) + } + if lipgloss.Height(modal) >= m.height && lipgloss.Width(modal) >= m.width { + return modal // already a full-screen composite; don't re-dim it + } + return m.compositeOverlay(m.lastBase, modal) +} + +// compositeOverlay strips + dims base into a faint scrim, then splices the modal box +// over it centered. ANSI-aware so the side margins keep the (dimmed) content behind. +// base and the result are both m.width × m.height. +func (m Model) compositeOverlay(base, modal string) string { + w, h := m.width, m.height + dim := lipgloss.NewStyle().Foreground(m.theme.P.Faint).Background(m.theme.P.BgDeep) + baseLines := strings.Split(base, "\n") + scrim := make([]string, h) + for i := 0; i < h; i++ { + raw := "" + if i < len(baseLines) { + raw = ansi.Strip(baseLines[i]) + } + scrim[i] = dim.Render(padRightRaw(raw, w)) + } + modalLines := strings.Split(modal, "\n") + mw := lipgloss.Width(modal) + top := (h - len(modalLines)) / 2 + left := (w - mw) / 2 + if top < 0 { + top = 0 + } + if left < 0 { + left = 0 + } + for r, ml := range modalLines { + row := top + r + if row < 0 || row >= h { + continue + } + leftPart := ansi.Truncate(scrim[row], left, "") + rightPart := ansi.TruncateLeft(scrim[row], left+mw, "") + scrim[row] = leftPart + ml + rightPart + } + return strings.Join(scrim, "\n") +} + +// padRightRaw pads an unstyled string with spaces to exactly w columns (or truncates). +func padRightRaw(s string, w int) string { + switch sw := ansi.StringWidth(s); { + case sw > w: + return ansi.Truncate(s, w, "") + case sw < w: + return s + strings.Repeat(" ", w-sw) + default: + return s + } } func (m Model) modalWidth() int { diff --git a/apps/tui-go/internal/app/view.go b/apps/tui-go/internal/app/view.go index 1c4b4451..acbd5b9d 100644 --- a/apps/tui-go/internal/app/view.go +++ b/apps/tui-go/internal/app/view.go @@ -54,6 +54,8 @@ func (m Model) View() string { bottom, m.renderFooter(), ) + // Stash the base so center() can composite a modal over a dimmed copy of it. + m.lastBase = base if m.displayState() == StateClarification { return m.center(m.clarificationModal()) From ba7ac06c166a82b413cc81fa4b5a91d82f814be4 Mon Sep 17 00:00:00 2001 From: kami Date: Sun, 21 Jun 2026 09:59:29 +0000 Subject: [PATCH 040/107] feat(tui-go): transcript message-jump + OSC52 copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ctrl+↑/↓ jumps between your sent (user) messages in the transcript: ctrl+↑ from the bottom selects the most recent, steps to older ones (clamping); ctrl+↓ steps back and drops to tail-follow past the last. The selected message is highlighted and scrolled into view (routerRows tracks per-message row offsets and windows to the selection). `y` yanks the selected message — or the latest turn when none is selected — to the system clipboard over OSC52 (go-osc52, tmux-wrapped under $TMUX), which is SSH/Termius- safe unlike a mouse drag. A brief "✓ copied" footer note animates out (and self-stops the tick). esc drops the selection; switching sessions clears it. Tests cover the user-message jump/clamp/tail and the copy-text selection. Co-Authored-By: Claude Opus 4.8 --- apps/tui-go/go.mod | 2 +- apps/tui-go/internal/app/model.go | 6 ++ apps/tui-go/internal/app/transcript_test.go | 67 ++++++++++++ apps/tui-go/internal/app/update.go | 112 ++++++++++++++++++++ apps/tui-go/internal/app/view.go | 31 +++++- 5 files changed, 213 insertions(+), 5 deletions(-) create mode 100644 apps/tui-go/internal/app/transcript_test.go diff --git a/apps/tui-go/go.mod b/apps/tui-go/go.mod index 84392ea9..0a9c3c3e 100644 --- a/apps/tui-go/go.mod +++ b/apps/tui-go/go.mod @@ -3,6 +3,7 @@ module github.com/correx/tui-go go 1.24.2 require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/glamour v1.0.0 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 @@ -13,7 +14,6 @@ require ( require ( github.com/alecthomas/chroma/v2 v2.20.0 // indirect - github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect diff --git a/apps/tui-go/internal/app/model.go b/apps/tui-go/internal/app/model.go index edb7bd73..ec2d715e 100644 --- a/apps/tui-go/internal/app/model.go +++ b/apps/tui-go/internal/app/model.go @@ -241,6 +241,11 @@ type Model struct { routerMessages map[string][]RouterEntry routerConnected bool chatMode string + // transcriptSel is the index (into the selected session's transcript) of the message + // the operator has navigated to with ctrl+↑/↓; -1 = no selection (tail-follow). A + // selected message is highlighted, scrolled into view, and is what `y` copies. + transcriptSel int + copiedFlash int // frame at which a copy happened, for a brief "copied" footer note // provider currentModel string @@ -353,6 +358,7 @@ func NewModel(client *ws.Client) Model { providerType: "LOCAL", snapshotPhase: true, eventStripShown: true, + transcriptSel: -1, } } diff --git a/apps/tui-go/internal/app/transcript_test.go b/apps/tui-go/internal/app/transcript_test.go new file mode 100644 index 00000000..073a99b7 --- /dev/null +++ b/apps/tui-go/internal/app/transcript_test.go @@ -0,0 +1,67 @@ +package app + +import "testing" + +func transcriptModel(roles ...string) Model { + m := NewModel(nil) + m.selectedID = "s1" + msgs := make([]RouterEntry, len(roles)) + for i, r := range roles { + msgs[i] = RouterEntry{Role: r, Content: r + itoa(i)} + } + m.routerMessages["s1"] = msgs + return m +} + +// ctrl+↑/↓ jumps between USER messages: from no selection ↑ grabs the most recent user +// message, keeps stepping to older ones (clamping), and ↓ steps back down, dropping to +// tail-follow (-1) once past the last user message. +func TestTranscriptNavUser(t *testing.T) { + // indices: 0 1 2 3 4 + m := transcriptModel("user", "router", "user", "router", "user") + + if m.transcriptSel != -1 { + t.Fatalf("initial sel = %d, want -1", m.transcriptSel) + } + m.transcriptNavUser(-1) // up from bottom → newest user (idx 4) + if m.transcriptSel != 4 { + t.Fatalf("up#1 = %d, want 4", m.transcriptSel) + } + m.transcriptNavUser(-1) // → user idx 2 + if m.transcriptSel != 2 { + t.Fatalf("up#2 = %d, want 2", m.transcriptSel) + } + m.transcriptNavUser(-1) // → user idx 0 + if m.transcriptSel != 0 { + t.Fatalf("up#3 = %d, want 0", m.transcriptSel) + } + m.transcriptNavUser(-1) // clamp at oldest + if m.transcriptSel != 0 { + t.Fatalf("up#4 (clamp) = %d, want 0", m.transcriptSel) + } + m.transcriptNavUser(1) // → user idx 2 + if m.transcriptSel != 2 { + t.Fatalf("down#1 = %d, want 2", m.transcriptSel) + } + m.transcriptNavUser(1) // → user idx 4 + m.transcriptNavUser(1) // past last user → tail-follow + if m.transcriptSel != -1 { + t.Fatalf("down past last = %d, want -1 (tail-follow)", m.transcriptSel) + } +} + +// `y` copies the selected message, or the latest user/router turn when nothing is selected. +func TestCopyText(t *testing.T) { + m := transcriptModel("user", "router", "tool") + + // no selection → most recent user/router turn (the "router" at idx 1, since "tool" + // is skipped). + if got := m.copyText(); got != "router1" { + t.Fatalf("copyText (no sel) = %q, want %q", got, "router1") + } + + m.transcriptSel = 0 + if got := m.copyText(); got != "user0" { + t.Fatalf("copyText (sel idx 0) = %q, want %q", got, "user0") + } +} diff --git a/apps/tui-go/internal/app/update.go b/apps/tui-go/internal/app/update.go index d2c4c415..1c16739f 100644 --- a/apps/tui-go/internal/app/update.go +++ b/apps/tui-go/internal/app/update.go @@ -1,9 +1,12 @@ package app import ( + "fmt" + "os" "strings" "time" + osc52 "github.com/aymanbagabas/go-osc52/v2" tea "github.com/charmbracelet/bubbletea" "github.com/correx/tui-go/internal/protocol" "github.com/correx/tui-go/internal/ws" @@ -15,6 +18,9 @@ type serverMsg struct{ m protocol.ServerMessage } type connMsg struct{ s ws.Status } type tickMsg struct{} +// copiedFlashFrames is how many ticks the "✓ copied" footer note lingers after a yank. +const copiedFlashFrames = 12 + func readServer(c *ws.Client) tea.Cmd { return func() tea.Msg { return serverMsg{<-c.Incoming()} } } @@ -48,6 +54,9 @@ func (m Model) animating() bool { if m.overlay == OverlayPalette { // the palette shows a blinking filter caret return true } + if m.copiedFlash != 0 && m.frame-m.copiedFlash < copiedFlashFrames { + return true // keep ticking briefly so the "copied" note animates out + } for i := range m.sessions { if m.sessions[i].Active { return true @@ -182,6 +191,18 @@ func (m Model) handleKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { if m.displayState() == StateWorkflowPropose { return m.handleProposeKey(k) } + // Transcript message-jump works in any edit mode while in-session, so you can scroll + // back through your sent messages without leaving the input. ctrl+↑ older, ctrl+↓ newer. + if m.displayState() == StateInSession { + switch k.Type { + case tea.KeyCtrlUp: + m.transcriptNavUser(-1) + return m, nil + case tea.KeyCtrlDown: + m.transcriptNavUser(1) + return m, nil + } + } if m.editMode == ModeInsert { return m.handleInsertKey(k) } @@ -207,6 +228,12 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { case tea.KeyEnter: return m.normalEnter() case tea.KeyEsc: + // In-session, esc first drops a transcript selection (back to tail-follow); + // otherwise it clears the session-list filter. + if m.transcriptSel >= 0 { + m.transcriptSel = -1 + return m, nil + } m.filter = "" return m, nil case tea.KeyCtrlX: @@ -265,6 +292,13 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { } case "s": m.cycleChatMode() + case "y": + // Copy the selected message (or the latest assistant turn if none is selected) + // to the system clipboard over OSC52 — SSH-safe, unlike a mouse drag. + if cmd := m.copyMessage(); cmd != nil { + m.copiedFlash = m.frame + return m, cmd + } case "c": if m.selectedID != "" { m.client.Send(protocol.CancelSession(m.selectedID)) @@ -959,6 +993,7 @@ func (m *Model) listNav(dir int) { } if m.selectedID != list[n].ID { m.bgUpdates = 0 + m.transcriptSel = -1 // a different session's transcript: drop the stale selection } m.selectedID = list[n].ID m.wfIndex = -1 @@ -1019,6 +1054,83 @@ func (m *Model) appendRouter(sid string, e RouterEntry) { m.routerMessages[sid] = append(m.routerMessages[sid], e) } +// transcriptNavUser moves the transcript selection to the previous (dir<0) or next +// (dir>0) USER message — "go back to my previous message". From no selection, ctrl+↑ +// grabs the most recent user message; ctrl+↓ past the last one drops back to tail-follow. +// Messages only ever append, so a stored index stays pointing at the same message. +func (m *Model) transcriptNavUser(dir int) { + msgs := m.routerMessages[m.selectedID] + n := len(msgs) + if n == 0 { + return + } + if dir < 0 { + start := m.transcriptSel + if start < 0 || start > n { + start = n // searching upward from the bottom + } + for i := start - 1; i >= 0; i-- { + if msgs[i].Role == "user" { + m.transcriptSel = i + return + } + } + return // already at the oldest user message + } + if m.transcriptSel < 0 { + return // already tailing the bottom + } + for i := m.transcriptSel + 1; i < n; i++ { + if msgs[i].Role == "user" { + m.transcriptSel = i + return + } + } + m.transcriptSel = -1 // past the last user message → resume tail-follow +} + +// copyMessage returns a command that copies the selected message — or, with no +// selection, the latest assistant/router turn — to the system clipboard via OSC52. +// Returns nil when there is nothing to copy. +func (m Model) copyMessage() tea.Cmd { + if text := m.copyText(); strings.TrimSpace(text) != "" { + return osc52Copy(text) + } + return nil +} + +// copyText is the content `y` will yank: the selected message, or — with no selection — +// the most recent user/router turn. Empty when there is nothing to copy. +func (m Model) copyText() string { + msgs := m.routerMessages[m.selectedID] + if len(msgs) == 0 { + return "" + } + if m.transcriptSel >= 0 && m.transcriptSel < len(msgs) { + return msgs[m.transcriptSel].Content + } + for i := len(msgs) - 1; i >= 0; i-- { + if r := msgs[i].Role; r == "router" || r == "user" { + return msgs[i].Content + } + } + return "" +} + +// osc52Copy writes the clipboard sequence to stderr (separate from the alt-screen on +// stdout). Wrapped for tmux passthrough when running inside tmux so the copy still +// reaches the outer terminal (e.g. over SSH / Termius). +func osc52Copy(text string) tea.Cmd { + return func() tea.Msg { + seq := osc52.New(text) + if os.Getenv("TMUX") != "" { + seq = seq.Tmux() + } + fmt.Fprint(os.Stderr, seq) + return nil + } +} + // currentDiff returns the most recent tool diff in the selected transcript. func (m Model) currentDiff() string { if s := m.session(m.selectedID); s != nil && s.Pending != nil && s.Pending.Preview != "" { diff --git a/apps/tui-go/internal/app/view.go b/apps/tui-go/internal/app/view.go index acbd5b9d..75e3d568 100644 --- a/apps/tui-go/internal/app/view.go +++ b/apps/tui-go/internal/app/view.go @@ -244,9 +244,12 @@ func (m Model) renderFooter() string { } default: // StateInSession if nrw { - hints = []string{hint("i", "msg"), hint("e", "events"), hint("S", "stats"), hint("l", "back"), hint("p", "cmds"), hint("q", "quit")} + hints = []string{hint("i", "msg"), hint("^↑↓", "msgs"), hint("y", "copy"), hint("e", "events"), hint("l", "back"), hint("p", "cmds"), hint("q", "quit")} } else { - hints = []string{hint("i", "message"), hint("e", "events"), hint("t", "tools"), hint("S", "stats"), hint("m", "model"), hint("s", "mode"), hint("l", "back"), hint("p", "cmds"), hint("q", "quit")} + hints = []string{hint("i", "message"), hint("^↑↓", "msgs"), hint("y", "copy"), hint("e", "events"), hint("t", "tools"), hint("S", "stats"), hint("m", "model"), hint("l", "back"), hint("p", "cmds"), hint("q", "quit")} + } + if m.copiedFlash != 0 && m.frame-m.copiedFlash < copiedFlashFrames { + hints = append(hints, lipgloss.NewStyle().Foreground(t.P.OK).Background(bg).Bold(true).Render("✓ copied")) } if s := m.session(m.selectedID); s != nil && s.Pending != nil { hints = append(hints, hint("a", "approval (pending)")) @@ -488,9 +491,18 @@ func (m Model) routerRows(w, h int) []string { return []string{t.span("awaiting router response…", t.P.Faint)} } var rows []string - for _, e := range msgs { + msgStart := make([]int, len(msgs)) // first row index of each message, for scroll-to-selection + for mi, e := range msgs { + msgStart[mi] = len(rows) switch e.Role { case "user": + if mi == m.transcriptSel { + // Selected message: an inverted tag + a highlighted background bar so it's + // unmistakable which one ctrl+↑/↓ landed on (and what `y` will copy). + tag := lipgloss.NewStyle().Foreground(t.P.BgDeep).Background(t.P.Accent).Bold(true).Render(" › ") + rows = append(rows, tag+" "+lipgloss.NewStyle().Foreground(t.P.FgStrong).Background(t.P.Sel).Render(e.Content)) + break + } tag := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render("›") rows = append(rows, tag+" "+t.span(e.Content, t.P.FgStrong)) case "router": @@ -525,7 +537,18 @@ func (m Model) routerRows(w, h int) []string { rows = append(rows, t.span(e.Content, t.P.Dim)) } } - // keep last h rows + // With a selection, scroll so the selected message is visible (top-aligned, clamped + // to the end); otherwise tail-follow the newest output. + if m.transcriptSel >= 0 && m.transcriptSel < len(msgStart) && len(rows) > h { + start := msgStart[m.transcriptSel] + if start > len(rows)-h { + start = len(rows) - h + } + if start < 0 { + start = 0 + } + return rows[start : start+h] + } if len(rows) > h { rows = rows[len(rows)-h:] } From 65e5d88ed70581e4972705be2b1974b869913a79 Mon Sep 17 00:00:00 2001 From: kami Date: Sun, 21 Jun 2026 10:19:21 +0000 Subject: [PATCH 041/107] feat(server): ListFiles/FileList protocol for the TUI @ file picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New WS query: ClientMessage.ListFiles{sessionId} -> ServerMessage.FileList{paths} (@SerialName "file.list"). StreamQueries.listFiles resolves the session's bound workspace root (latest SessionWorkspaceBoundEvent) and lists it via WorkspaceFiles — a path-only walk that reuses RepoMapIndexer's directory-ignore convention but, unlike the indexer, never reads file contents and includes every file type (configs/assets, not just source+markdown), sorted and capped. Tests: WorkspaceFiles (types/ignore/cap/ missing) + file.list wire round-trip. Go @ autocomplete consumes this next. Co-Authored-By: Claude Opus 4.8 --- .../apps/server/protocol/ClientMessage.kt | 4 ++ .../apps/server/protocol/ServerMessage.kt | 13 +++++ .../apps/server/workspace/WorkspaceFiles.kt | 46 ++++++++++++++++ .../apps/server/ws/GlobalStreamHandler.kt | 1 + .../correx/apps/server/ws/StreamQueries.kt | 22 ++++++++ .../ServerMessageSerializationTest.kt | 18 ++++++ .../server/workspace/WorkspaceFilesTest.kt | 55 +++++++++++++++++++ 7 files changed, 159 insertions(+) create mode 100644 apps/server/src/main/kotlin/com/correx/apps/server/workspace/WorkspaceFiles.kt create mode 100644 apps/server/src/test/kotlin/com/correx/apps/server/workspace/WorkspaceFilesTest.kt diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ClientMessage.kt b/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ClientMessage.kt index 550322e7..b7d2fdc6 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ClientMessage.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ClientMessage.kt @@ -44,6 +44,10 @@ sealed class ClientMessage { @Serializable data class ListArtifacts(val sessionId: SessionId) : ClientMessage() + /** Operator request for the session workspace's file paths (the TUI `@` file-ref picker). */ + @Serializable + data class ListFiles(val sessionId: SessionId) : ClientMessage() + /** Operator request for a session's derived metrics (replied to with a SessionStats). */ @Serializable data class GetSessionStats(val sessionId: SessionId) : ClientMessage() diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ServerMessage.kt b/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ServerMessage.kt index 31625a57..e840e213 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ServerMessage.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ServerMessage.kt @@ -464,6 +464,19 @@ sealed interface ServerMessage { override val sessionSequence: Long? = null, ) : ServerMessage, NonEventMessage + /** + * The session workspace's file paths (reply to ListFiles), for the TUI `@` file-ref picker. + * A snapshot directory walk, not event-derived, so cursors are null. + */ + @Serializable + @SerialName("file.list") + data class FileList( + val sessionId: SessionId, + val paths: List, + override val sequence: Long? = null, + override val sessionSequence: Long? = null, + ) : ServerMessage, NonEventMessage + /** * The cross-session idea board (reply to ListIdeas / DiscardIdea). Not event-derived (a * snapshot read folded from the whole event log), so cursors are null. diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/workspace/WorkspaceFiles.kt b/apps/server/src/main/kotlin/com/correx/apps/server/workspace/WorkspaceFiles.kt new file mode 100644 index 00000000..b2baa73a --- /dev/null +++ b/apps/server/src/main/kotlin/com/correx/apps/server/workspace/WorkspaceFiles.kt @@ -0,0 +1,46 @@ +package com.correx.apps.server.workspace + +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.isRegularFile +import kotlin.io.path.name +import kotlin.io.path.relativeTo + +/** + * Lists workspace-relative file paths under a root for the TUI's `@` file-reference picker. + * + * Deliberately distinct from `RepoMapIndexer`: that one reads every file's *contents* to extract + * symbols and only covers source/markdown extensions (it feeds project memory + context). The `@` + * picker wants *all* file types, by path only, as cheaply as possible — so this never opens a file. + * It does share the indexer's directory-ignore convention (skip any path segment in [ignoreDirs]) + * so both agree on what "the workspace" is. Results are sorted and capped to bound the reply. + */ +object WorkspaceFiles { + + val DEFAULT_IGNORE_DIRS = setOf( + ".git", "node_modules", "build", "target", ".gradle", "dist", ".idea", ".correx", ".venv", + ) + const val DEFAULT_LIMIT = 2000 + private const val MAX_DEPTH = 12 + + fun list( + root: Path, + ignoreDirs: Set = DEFAULT_IGNORE_DIRS, + limit: Int = DEFAULT_LIMIT, + ): List { + if (!Files.isDirectory(root)) return emptyList() + val out = ArrayList() + Files.walk(root, MAX_DEPTH).use { stream -> + val iter = stream.iterator() + while (iter.hasNext()) { + val path = iter.next() + if (!path.isRegularFile()) continue + val rel = path.relativeTo(root) + if (rel.any { it.name in ignoreDirs }) continue + out.add(rel.toString()) + } + } + out.sort() + return out.take(limit) + } +} diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/ws/GlobalStreamHandler.kt b/apps/server/src/main/kotlin/com/correx/apps/server/ws/GlobalStreamHandler.kt index a850106b..b937dd06 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/ws/GlobalStreamHandler.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/ws/GlobalStreamHandler.kt @@ -235,6 +235,7 @@ class GlobalStreamHandler(private val module: ServerModule) { .onFailure { log.error("cancel failed for session={}: {}", msg.sessionId.value, it.message, it) } } is ClientMessage.ListArtifacts -> sendFrame(queries.listArtifacts(msg.sessionId)) + is ClientMessage.ListFiles -> sendFrame(queries.listFiles(msg.sessionId)) is ClientMessage.ListIdeas -> sendFrame(queries.listIdeas()) is ClientMessage.DiscardIdea -> handleDiscardIdea(msg, sendFrame) is ClientMessage.PromoteIdea -> handlePromoteIdea(msg, sendFrame) diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/ws/StreamQueries.kt b/apps/server/src/main/kotlin/com/correx/apps/server/ws/StreamQueries.kt index 0c1331ff..82c7c94f 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/ws/StreamQueries.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/ws/StreamQueries.kt @@ -7,6 +7,7 @@ import com.correx.apps.server.protocol.ArtifactSummaryDto import com.correx.apps.server.protocol.ConfigFieldDto import com.correx.apps.server.protocol.IdeaDto import com.correx.apps.server.protocol.ServerMessage +import com.correx.apps.server.workspace.WorkspaceFiles import com.correx.core.router.IdeaReader import com.correx.core.config.EditableConfig import com.correx.core.events.events.ArtifactContentStoredEvent @@ -14,11 +15,13 @@ import com.correx.core.events.events.ArtifactCreatedEvent import com.correx.core.events.events.ArtifactValidatedEvent import com.correx.core.events.events.ArtifactValidatingEvent import com.correx.core.events.events.InferenceCompletedEvent +import com.correx.core.events.events.SessionWorkspaceBoundEvent import com.correx.core.events.events.StoredEvent import com.correx.core.events.types.ArtifactId import com.correx.core.events.types.SessionId import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import java.nio.file.Paths import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.jsonObject @@ -48,6 +51,25 @@ class StreamQueries(private val module: ServerModule) { return ServerMessage.SessionStats(sessionId = sessionId, stats = report) } + /** + * The session workspace's file paths for the `@` picker. Resolves the bound workspace root from + * the recorded [SessionWorkspaceBoundEvent] (invariant #9) and lists it by path. Pure snapshot + * read — no events appended (replay-neutral). Empty when the session has no bound workspace. + */ + suspend fun listFiles(sessionId: SessionId): ServerMessage.FileList { + val paths = withContext(Dispatchers.IO) { + workspaceRootOf(sessionId)?.let { WorkspaceFiles.list(Paths.get(it)) } ?: emptyList() + } + return ServerMessage.FileList(sessionId = sessionId, paths = paths) + } + + // The workspace a session was bound to, from its latest SessionWorkspaceBoundEvent, or null. + private fun workspaceRootOf(sessionId: SessionId): String? = + module.eventStore.read(sessionId) + .mapNotNull { it.payload as? SessionWorkspaceBoundEvent } + .lastOrNull() + ?.workspaceRoot + /** * Reads a session's events to assemble its artifact listing (creation order preserved), * resolving each artifact's stored bytes via the CAS artifact store. Pure snapshot read — diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/protocol/ServerMessageSerializationTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/protocol/ServerMessageSerializationTest.kt index 85ec1665..63c15a84 100644 --- a/apps/server/src/test/kotlin/com/correx/apps/server/protocol/ServerMessageSerializationTest.kt +++ b/apps/server/src/test/kotlin/com/correx/apps/server/protocol/ServerMessageSerializationTest.kt @@ -275,6 +275,24 @@ class ServerMessageSerializationTest { assertEquals("i1", (decoded as ClientMessage.PromoteIdea).ideaId) } + @Test + fun `FileList encodes type file_list and paths`() { + val msg = ServerMessage.FileList(sessionId = SessionId("s1"), paths = listOf("README.md", "src/Main.kt")) + val jsonStr = ProtocolSerializer.encodeServerMessage(msg) + assert(jsonStr.contains("\"type\":\"file.list\"")) { "expected type=file.list" } + + val decoded = json.decodeFromString(jsonStr) + assertEquals(listOf("README.md", "src/Main.kt"), decoded.paths) + } + + @Test + fun `ListFiles decodes from the client wire format`() { + val wire = """{"type":"com.correx.apps.server.protocol.ClientMessage.ListFiles","sessionId":"s1"}""" + val decoded = ProtocolSerializer.decodeClientMessage(wire) + assert(decoded is ClientMessage.ListFiles) { "expected ListFiles, got $decoded" } + assertEquals("s1", (decoded as ClientMessage.ListFiles).sessionId.value) + } + @Test fun `ClarificationResponse decodes from the client wire format`() { val wire = """ diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/workspace/WorkspaceFilesTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/workspace/WorkspaceFilesTest.kt new file mode 100644 index 00000000..62527e61 --- /dev/null +++ b/apps/server/src/test/kotlin/com/correx/apps/server/workspace/WorkspaceFilesTest.kt @@ -0,0 +1,55 @@ +package com.correx.apps.server.workspace + +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.createDirectories +import kotlin.io.path.writeText +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class WorkspaceFilesTest { + + @Test + fun `lists all file types by relative path, sorted`(@TempDir root: Path) { + root.resolve("src").createDirectories() + root.resolve("src/Main.kt").writeText("fun main() {}") + root.resolve("config.toml").writeText("a = 1") // not a source ext — must still appear + root.resolve("README.md").writeText("# hi") + + val paths = WorkspaceFiles.list(root) + + assertEquals(listOf("README.md", "config.toml", "src/Main.kt"), paths) + } + + @Test + fun `skips ignored directories`(@TempDir root: Path) { + root.resolve(".git").createDirectories() + root.resolve(".git/HEAD").writeText("ref") + root.resolve("node_modules/pkg").createDirectories() + root.resolve("node_modules/pkg/index.js").writeText("x") + root.resolve("keep.txt").writeText("y") + + val paths = WorkspaceFiles.list(root) + + assertEquals(listOf("keep.txt"), paths) + assertFalse(paths.any { it.startsWith(".git") || it.startsWith("node_modules") }) + } + + @Test + fun `caps the result count`(@TempDir root: Path) { + repeat(10) { root.resolve("f$it.txt").writeText("x") } + val paths = WorkspaceFiles.list(root, limit = 3) + assertEquals(3, paths.size) + assertTrue(paths.all { it.endsWith(".txt") }) + } + + @Test + fun `missing root yields empty`(@TempDir root: Path) { + val missing = root.resolve("nope") + assertTrue(Files.notExists(missing)) + assertEquals(emptyList(), WorkspaceFiles.list(missing)) + } +} From 04d7e26482105c5c55f94e57f241bd1113928558 Mon Sep 17 00:00:00 2001 From: kami Date: Sun, 21 Jun 2026 10:24:57 +0000 Subject: [PATCH 042/107] feat(tui-go): @ file-reference picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing `@` at a word boundary in the chat input (mid-word `@` stays literal, e.g. emails) opens a workspace file picker: it requests file.list for the session (cached per session), filters as you type, and enter inserts `@path ` at the cursor — back to typing. Renders as a transparent overlay, windowed around the selection. file.list is classified non-rendering in the render-matrix guard. Tests cover token-boundary detection, ref insertion, and filtering. Co-Authored-By: Claude Opus 4.8 --- apps/tui-go/internal/app/file_picker_test.go | 62 ++++++++++++ apps/tui-go/internal/app/model.go | 8 ++ apps/tui-go/internal/app/overlays.go | 59 ++++++++++++ .../tui-go/internal/app/render_matrix_test.go | 1 + apps/tui-go/internal/app/server.go | 9 +- apps/tui-go/internal/app/update.go | 95 ++++++++++++++++++- apps/tui-go/internal/app/view.go | 3 + apps/tui-go/internal/protocol/protocol.go | 10 ++ 8 files changed, 245 insertions(+), 2 deletions(-) create mode 100644 apps/tui-go/internal/app/file_picker_test.go diff --git a/apps/tui-go/internal/app/file_picker_test.go b/apps/tui-go/internal/app/file_picker_test.go new file mode 100644 index 00000000..fe89314a --- /dev/null +++ b/apps/tui-go/internal/app/file_picker_test.go @@ -0,0 +1,62 @@ +package app + +import "testing" + +func TestAtTokenBoundary(t *testing.T) { + cases := []struct { + buf string + cur int + want bool + }{ + {"", 0, true}, // start of empty line + {"see ", 4, true}, // just after a space + {"email", 5, false}, // mid-word (e.g. user@host) + {"a b", 2, true}, // after the space + {"a b", 3, false}, // after 'b' + } + for _, c := range cases { + m := NewModel(nil) + m.inputBuffer = c.buf + m.inputCursor = c.cur + if got := m.atTokenBoundary(); got != c.want { + t.Errorf("atTokenBoundary(%q,@%d) = %v, want %v", c.buf, c.cur, got, c.want) + } + } +} + +// `@` consumes itself to open the picker; selecting a file inserts `@path ` at the cursor. +func TestInsertFileRef(t *testing.T) { + m := NewModel(nil) + m.inputBuffer = "look at " + m.inputCursor = len(m.inputBuffer) + + m.insertFileRef("src/main.go") + + if want := "look at @src/main.go "; m.inputBuffer != want { + t.Fatalf("buffer = %q, want %q", m.inputBuffer, want) + } + if m.inputCursor != len(m.inputBuffer) { + t.Fatalf("cursor = %d, want %d", m.inputCursor, len(m.inputBuffer)) + } +} + +func TestFilteredFiles(t *testing.T) { + m := NewModel(nil) + m.files = []string{"cmd/main.go", "internal/app/view.go", "README.md", "internal/app/model.go"} + + m.fileFilter = "" + if len(m.filteredFiles()) != 4 { + t.Fatalf("empty filter should pass all 4, got %d", len(m.filteredFiles())) + } + + m.fileFilter = "app/" + got := m.filteredFiles() + if len(got) != 2 { + t.Fatalf("filter %q matched %d, want 2 (%v)", m.fileFilter, len(got), got) + } + + m.fileFilter = "README" + if got := m.filteredFiles(); len(got) != 1 || got[0] != "README.md" { + t.Fatalf("filter README = %v, want [README.md]", got) + } +} diff --git a/apps/tui-go/internal/app/model.go b/apps/tui-go/internal/app/model.go index ec2d715e..9da2dc0c 100644 --- a/apps/tui-go/internal/app/model.go +++ b/apps/tui-go/internal/app/model.go @@ -57,6 +57,7 @@ const ( OverlayStats OverlayIdeas OverlaySessions + OverlayFiles ) // RouterEntry is one line in a session's conversation transcript. @@ -306,6 +307,13 @@ type Model struct { paletteFilter string paletteIndex int + // @ file picker (OverlayFiles) — workspace paths from the file.list reply + files []string // all workspace-relative paths for filesFor + filesFor string // sessionId the file list belongs to + filesLoading bool + fileFilter string // the query typed after @ (narrows the list) + fileIndex int + // animation frame int // tick counter; drives spinner + caret blink ticking bool // true while a tick loop is scheduled. The loop is gated on animating() diff --git a/apps/tui-go/internal/app/overlays.go b/apps/tui-go/internal/app/overlays.go index fe5370d4..12d55146 100644 --- a/apps/tui-go/internal/app/overlays.go +++ b/apps/tui-go/internal/app/overlays.go @@ -124,6 +124,8 @@ func (m Model) renderOverlay(base string) string { return m.center(m.ideasModal()) case OverlaySessions: return m.center(m.sessionsModal()) + case OverlayFiles: + return m.center(m.filesModal()) } return base } @@ -520,6 +522,63 @@ func (m Model) paletteModal() string { return m.center(modal) } +// filesModal is the `@` file-reference picker: a filter line over the session workspace's +// paths, windowed around the selection; enter inserts `@path` into the chat input. +func (m Model) filesModal() string { + t := m.theme + w := m.modalWidth() + files := m.filteredFiles() + + var b strings.Builder + b.WriteString(m.titleLine("@ file reference") + "\n\n") + + caret := mbg(t, " ", t.P.BgPanel) + if m.caretVisible() { + caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▏") + } + prompt := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("@ ") + if m.fileFilter == "" { + b.WriteString(prompt + caret + mbg(t, "type to filter files…", t.P.Faint) + "\n\n") + } else { + b.WriteString(prompt + mbg(t, m.fileFilter, t.P.FgStrong) + caret + "\n\n") + } + + switch { + case m.filesLoading: + b.WriteString(mbg(t, " loading…", t.P.Faint) + "\n") + case len(m.files) == 0: + b.WriteString(mbg(t, " no workspace files (is a workspace bound?)", t.P.Faint) + "\n") + case len(files) == 0: + b.WriteString(mbg(t, " no matching files", t.P.Faint) + "\n") + default: + const maxRows = 12 + start := 0 + if m.fileIndex >= maxRows { + start = m.fileIndex - maxRows + 1 + } + end := start + maxRows + if end > len(files) { + end = len(files) + } + for i := start; i < end; i++ { + marker := mbg(t, " ", t.P.BgPanel) + fg := t.P.Fg + if i == m.fileIndex { + marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▌ ") + fg = t.P.FgStrong + } + b.WriteString(marker + + lipgloss.NewStyle().Foreground(fg).Background(t.P.BgPanel).Render(clip(files[i], w-6)) + "\n") + } + if len(files) > maxRows { + b.WriteString(mbg(t, " "+itoa(len(files))+" matches", t.P.Faint) + "\n") + } + } + b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "select"}, {"enter", "insert"}, {"esc", "close"}})) + modal := t.Overlay.Width(w).Render(b.String()) + return m.center(modal) +} + func (m Model) toolPaletteModal() string { t := m.theme w := m.modalWidth() diff --git a/apps/tui-go/internal/app/render_matrix_test.go b/apps/tui-go/internal/app/render_matrix_test.go index 82c8b72b..55af662a 100644 --- a/apps/tui-go/internal/app/render_matrix_test.go +++ b/apps/tui-go/internal/app/render_matrix_test.go @@ -597,6 +597,7 @@ var nonRenderingEventTypes = map[string]string{ protocol.TypeSnapshotComplete: "control frame: ends the snapshot-buffering phase; no render", protocol.TypeRouterResponse: "recognized no-op: superseded by chat.turn on the global stream", protocol.TypeProtocolError: "transport diagnostic: explicit no-op, not surfaced", + protocol.TypeFileList: "query reply: populates the @ file picker overlay, not the transcript", } // TestRenderMatrixCoversEveryEventType is the load-bearing matrix guarantee: it scans diff --git a/apps/tui-go/internal/app/server.go b/apps/tui-go/internal/app/server.go index 60039e5b..2ff8cb4a 100644 --- a/apps/tui-go/internal/app/server.go +++ b/apps/tui-go/internal/app/server.go @@ -326,6 +326,13 @@ func (m *Model) applyServer(msg protocol.ServerMessage) { if m.ideasIndex >= len(m.ideas) { m.ideasIndex = 0 } + case protocol.TypeFileList: + m.files = msg.Paths + m.filesFor = msg.SessionID + m.filesLoading = false + if m.fileIndex >= len(m.filteredFiles()) { + m.fileIndex = 0 + } case protocol.TypeConfigSnapshot: m.configFields = msg.ConfigFields m.configRestart = msg.ConfigRestartRequired @@ -371,7 +378,7 @@ func sessionIDOf(msg protocol.ServerMessage) string { protocol.TypeWorkflowList, protocol.TypeRouterResponse, protocol.TypeModelChanged, protocol.TypeModelList, protocol.TypeResourceStatus, protocol.TypeArtifactList, protocol.TypeConfigSnapshot, protocol.TypeSessionStats, - protocol.TypeIdeaList: + protocol.TypeIdeaList, protocol.TypeFileList: return "" default: return msg.SessionID diff --git a/apps/tui-go/internal/app/update.go b/apps/tui-go/internal/app/update.go index 1c16739f..2dfd3b04 100644 --- a/apps/tui-go/internal/app/update.go +++ b/apps/tui-go/internal/app/update.go @@ -21,6 +21,9 @@ type tickMsg struct{} // copiedFlashFrames is how many ticks the "✓ copied" footer note lingers after a yank. const copiedFlashFrames = 12 +// fileListMax caps how many @-picker rows are filtered/rendered at once. +const fileListMax = 200 + func readServer(c *ws.Client) tea.Cmd { return func() tea.Msg { return serverMsg{<-c.Incoming()} } } @@ -51,7 +54,7 @@ func (m Model) animating() bool { if m.reconnecting { return true } - if m.overlay == OverlayPalette { // the palette shows a blinking filter caret + if m.overlay == OverlayPalette || m.overlay == OverlayFiles { // blinking filter caret return true } if m.copiedFlash != 0 && m.frame-m.copiedFlash < copiedFlashFrames { @@ -448,6 +451,12 @@ func (m Model) handleInsertKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { m.openPalette() return m, nil } + // `@` at a token boundary opens the workspace file picker; the selection inserts + // `@path`. Mid-word `@` (e.g. an email) is literal. + if m.inputMode == ModeRouter && string(k.Runes) == "@" && m.atTokenBoundary() { + m.openFiles() + return m, nil + } m.appendRunes(string(k.Runes)) m.syncFilter() } @@ -462,6 +471,65 @@ func (m *Model) openPalette() { m.paletteIndex = 0 } +// atTokenBoundary reports whether the input cursor sits at the start of a word (start of +// line or just after a space) — where `@` should begin a file reference rather than be literal. +func (m Model) atTokenBoundary() bool { + if m.inputCursor <= 0 { + return true + } + if m.inputCursor <= len(m.inputBuffer) { + return m.inputBuffer[m.inputCursor-1] == ' ' + } + return false +} + +// openFiles opens the `@` file picker for the selected session and requests its workspace +// paths (cached per session). Editing mode is left in Insert so closing returns to typing. +func (m *Model) openFiles() { + m.overlay = OverlayFiles + m.fileFilter = "" + m.fileIndex = 0 + if m.filesFor != m.selectedID { + m.files = nil + m.filesLoading = true + } + m.client.Send(protocol.ListFiles(m.selectedID)) +} + +// insertFileRef inserts `@path ` at the input cursor (the `@` was consumed to open the picker). +func (m *Model) insertFileRef(path string) { + ref := "@" + path + " " + c := m.inputCursor + if c > len(m.inputBuffer) { + c = len(m.inputBuffer) + } + m.inputBuffer = m.inputBuffer[:c] + ref + m.inputBuffer[c:] + m.inputCursor = c + len(ref) + m.fileFilter = "" +} + +// filteredFiles narrows the workspace paths by the typed filter (case-insensitive substring), +// capped for render. +func (m Model) filteredFiles() []string { + if m.fileFilter == "" { + if len(m.files) > fileListMax { + return m.files[:fileListMax] + } + return m.files + } + f := strings.ToLower(m.fileFilter) + out := make([]string, 0, fileListMax) + for _, p := range m.files { + if strings.Contains(strings.ToLower(p), f) { + out = append(out, p) + if len(out) >= fileListMax { + break + } + } + } + return out +} + // beginIntent stashes the chosen workflow and switches to a text-entry prompt for the // freeform request. Submitting sends StartSession(id, intent); an empty line starts with no // intent (fixed-task workflows). Esc cancels. @@ -638,6 +706,31 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { case runeIs(k, "I"): m.overlay = OverlayNone } + case OverlayFiles: + files := m.filteredFiles() + switch { + case k.Type == tea.KeyUp: + if m.fileIndex > 0 { + m.fileIndex-- + } + case k.Type == tea.KeyDown: + if m.fileIndex < len(files)-1 { + m.fileIndex++ + } + case k.Type == tea.KeyEnter: + if m.fileIndex >= 0 && m.fileIndex < len(files) { + m.insertFileRef(files[m.fileIndex]) + } + m.overlay = OverlayNone // back to typing (still in insert mode) + case k.Type == tea.KeyBackspace: + if n := len(m.fileFilter); n > 0 { + m.fileFilter = m.fileFilter[:n-1] + m.fileIndex = 0 + } + case k.Type == tea.KeyRunes || k.Type == tea.KeySpace: + m.fileFilter += string(k.Runes) + m.fileIndex = 0 + } } return m, nil } diff --git a/apps/tui-go/internal/app/view.go b/apps/tui-go/internal/app/view.go index 75e3d568..ec977b75 100644 --- a/apps/tui-go/internal/app/view.go +++ b/apps/tui-go/internal/app/view.go @@ -227,6 +227,9 @@ func (m Model) renderFooter() string { if m.inputMode == ModeRouter && m.inputBuffer == "" { hints = append(hints, hint("/", "cmds")) } + if m.inputMode == ModeRouter { + hints = append(hints, hint("@", "files")) + } case m.displayState() == StateApproval: // Approval actions live in the band itself; the footer offers navigation only. hints = []string{hint("l", "back"), hint("e", "events"), hint("q", "quit")} diff --git a/apps/tui-go/internal/protocol/protocol.go b/apps/tui-go/internal/protocol/protocol.go index b7e3e41d..dc6b363e 100644 --- a/apps/tui-go/internal/protocol/protocol.go +++ b/apps/tui-go/internal/protocol/protocol.go @@ -55,6 +55,7 @@ const ( TypeConfigSnapshot = "config.snapshot" TypeSessionStats = "session.stats" TypeIdeaList = "idea.list" + TypeFileList = "file.list" ) // ServerMessage is a flat decode of every server->client variant. Field names @@ -150,6 +151,9 @@ type ServerMessage struct { // idea.list — the cross-session idea board Ideas []IdeaDto `json:"ideas"` + + // file.list — the session workspace's file paths (for the @ file-ref picker) + Paths []string `json:"paths"` } // IdeaDto is one idea on the cross-session board. Mirrors the Kotlin IdeaDto. @@ -386,6 +390,12 @@ func ListArtifacts(sessionID string) []byte { return encode("ListArtifacts", map[string]any{"sessionId": sessionID}) } +// ListFiles asks the server for the session workspace's file paths (replied to with file.list), +// used to populate the @ file-reference picker. +func ListFiles(sessionID string) []byte { + return encode("ListFiles", map[string]any{"sessionId": sessionID}) +} + // GetSessionStats asks the server for a session's derived metrics (replied to with session.stats). func GetSessionStats(sessionID string) []byte { return encode("GetSessionStats", map[string]any{"sessionId": sessionID}) From c616982b7b7863094c038ef254f3c9aff245ef96 Mon Sep 17 00:00:00 2001 From: kami Date: Sun, 21 Jun 2026 10:45:43 +0000 Subject: [PATCH 043/107] feat(context): inject CLAUDE.md / AGENTS.md as L0 standing context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On session start, discover CLAUDE.md and AGENTS.md at the bound workspace root and inject their (concatenated, header-labeled) contents as an L0 system context entry for every stage — mirroring the .correx/project.toml ProjectProfile path end to end. Recorded as AgentInstructionsBoundEvent (invariant #9) so replay reads the recorded fact, not the live file; folded into SessionState.boundAgentInstructions and rendered by buildAgentInstructionsEntry right after the project profile. AgentInstructionsLoader reads root-only, both files if present. Bound via ServerModule.bindAgentInstructions alongside bindProjectProfile. Co-Authored-By: Claude Opus 4.8 --- .../com/correx/apps/server/ServerModule.kt | 34 +++++++++++ .../correx/core/config/AgentInstructions.kt | 17 ++++++ .../core/config/AgentInstructionsLoader.kt | 35 ++++++++++++ .../config/AgentInstructionsLoaderTest.kt | 57 +++++++++++++++++++ .../core/events/events/SessionEvents.kt | 9 +++ .../events/serialization/Serialization.kt | 2 + ...ojectProfileBoundEventSerializationTest.kt | 15 +++++ .../kernel/orchestration/ContextFeedback.kt | 15 +++++ .../orchestration/SessionOrchestrator.kt | 5 +- .../core/sessions/BoundAgentInstructions.kt | 7 +++ .../core/sessions/DefaultSessionReducer.kt | 10 ++++ .../com/correx/core/sessions/SessionState.kt | 1 + .../src/test/kotlin/ContextFeedbackTest.kt | 17 ++++++ .../test/kotlin/DefaultSessionReducerTest.kt | 46 +++++++++++++++ 14 files changed, 269 insertions(+), 1 deletion(-) create mode 100644 core/config/src/main/kotlin/com/correx/core/config/AgentInstructions.kt create mode 100644 core/config/src/main/kotlin/com/correx/core/config/AgentInstructionsLoader.kt create mode 100644 core/config/src/test/kotlin/com/correx/core/config/AgentInstructionsLoaderTest.kt create mode 100644 core/sessions/src/main/kotlin/com/correx/core/sessions/BoundAgentInstructions.kt diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt b/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt index 269395a5..5a794926 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt @@ -11,9 +11,11 @@ import com.correx.core.approvals.ApprovalProjector import com.correx.core.approvals.DefaultApprovalReducer import com.correx.core.approvals.DefaultApprovalRepository import com.correx.core.artifactstore.ArtifactStore +import com.correx.core.config.AgentInstructionsLoader import com.correx.core.config.OperatorProfile import com.correx.core.config.ProjectProfileLoader import com.correx.apps.server.memory.ArchitectContradictionChecker +import com.correx.core.events.events.AgentInstructionsBoundEvent import com.correx.core.events.events.ApprovalRequestedEvent import com.correx.core.events.events.ArtifactContentStoredEvent import com.correx.core.events.events.ArtifactCreatedEvent @@ -322,6 +324,7 @@ class ServerModule( ) } bindProjectProfile(sessionId) + bindAgentInstructions(sessionId) runCatching { val result = orchestrator.run(sessionId, graph, sessionConfig) freestyleHandoff(sessionId, graph, result) @@ -405,6 +408,37 @@ class ServerModule( ) } + /** + * Bind standing agent instructions (CLAUDE.md / AGENTS.md at the workspace root) as an + * event so stages, router chat triage, and replay read the recorded snapshot, never the + * live file (invariants #8/#9). Mirrors [bindProjectProfile]. + */ + suspend fun bindAgentInstructions(sessionId: SessionId) { + val workspaceRoot = runCatching { + sessionRepository.getSession(sessionId).state.boundWorkspace?.workspaceRoot + }.getOrNull() ?: projectMemory?.repoRoot() ?: return + val instructions = withContext(Dispatchers.IO) { AgentInstructionsLoader.load(workspaceRoot) } + if (instructions.isEmpty()) return + eventStore.append( + NewEvent( + metadata = EventMetadata( + eventId = EventId(java.util.UUID.randomUUID().toString()), + sessionId = sessionId, + timestamp = Clock.System.now(), + schemaVersion = 1, + causationId = null, + correlationId = null, + ), + payload = AgentInstructionsBoundEvent( + sessionId = sessionId, + workspaceRoot = workspaceRoot, + sources = instructions.sources, + content = instructions.content, + ), + ), + ) + } + /** * Phase-2 handoff for freestyle sessions, shared by every launcher that can finish a * planning graph (fresh run and all resume paths). Locks the plan and executes it, diff --git a/core/config/src/main/kotlin/com/correx/core/config/AgentInstructions.kt b/core/config/src/main/kotlin/com/correx/core/config/AgentInstructions.kt new file mode 100644 index 00000000..f0358778 --- /dev/null +++ b/core/config/src/main/kotlin/com/correx/core/config/AgentInstructions.kt @@ -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 = emptyList(), + val content: String = "", +) { + fun isEmpty(): Boolean = sources.isEmpty() && content.isBlank() +} diff --git a/core/config/src/main/kotlin/com/correx/core/config/AgentInstructionsLoader.kt b/core/config/src/main/kotlin/com/correx/core/config/AgentInstructionsLoader.kt new file mode 100644 index 00000000..26acf784 --- /dev/null +++ b/core/config/src/main/kotlin/com/correx/core/config/AgentInstructionsLoader.kt @@ -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() + val sources = mutableListOf() + 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 + } + } +} diff --git a/core/config/src/test/kotlin/com/correx/core/config/AgentInstructionsLoaderTest.kt b/core/config/src/test/kotlin/com/correx/core/config/AgentInstructionsLoaderTest.kt new file mode 100644 index 00000000..5a66e986 --- /dev/null +++ b/core/config/src/test/kotlin/com/correx/core/config/AgentInstructionsLoaderTest.kt @@ -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) + } +} diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/SessionEvents.kt b/core/events/src/main/kotlin/com/correx/core/events/events/SessionEvents.kt index da552b48..5dbefe81 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/events/SessionEvents.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/events/SessionEvents.kt @@ -37,3 +37,12 @@ data class ProjectProfileBoundEvent( val conventions: List, val commands: Map, ) : EventPayload + +@Serializable +@SerialName("AgentInstructionsBound") +data class AgentInstructionsBoundEvent( + val sessionId: SessionId, + val workspaceRoot: String, + val sources: List, // file names found, e.g. ["CLAUDE.md","AGENTS.md"] + val content: String, // concatenated, header-labeled instructions +) : EventPayload diff --git a/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt b/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt index 64a985ae..fc752c38 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt @@ -1,5 +1,6 @@ package com.correx.core.events.serialization +import com.correx.core.events.events.AgentInstructionsBoundEvent import com.correx.core.events.events.ApprovalDecisionResolvedEvent import com.correx.core.events.events.ApprovalGrantCreatedEvent import com.correx.core.events.events.ApprovalGrantExpiredEvent @@ -126,6 +127,7 @@ val eventModule = SerializersModule { subclass(SessionWorkspaceBoundEvent::class) subclass(OperatorProfileBoundEvent::class) subclass(ProjectProfileBoundEvent::class) + subclass(AgentInstructionsBoundEvent::class) subclass(L3MemoryRetrievedEvent::class) subclass(ContextTruncatedEvent::class) subclass(ExecutionPlanLockedEvent::class) diff --git a/core/events/src/test/kotlin/com/correx/core/events/serialization/ProjectProfileBoundEventSerializationTest.kt b/core/events/src/test/kotlin/com/correx/core/events/serialization/ProjectProfileBoundEventSerializationTest.kt index 58627b9a..06367684 100644 --- a/core/events/src/test/kotlin/com/correx/core/events/serialization/ProjectProfileBoundEventSerializationTest.kt +++ b/core/events/src/test/kotlin/com/correx/core/events/serialization/ProjectProfileBoundEventSerializationTest.kt @@ -1,5 +1,6 @@ package com.correx.core.events.serialization +import com.correx.core.events.events.AgentInstructionsBoundEvent import com.correx.core.events.events.EventPayload import com.correx.core.events.events.ProjectProfileBoundEvent import com.correx.core.events.types.SessionId @@ -24,6 +25,20 @@ class ProjectProfileBoundEventSerializationTest { assertEquals(sample, decoded) } + @Test + fun `AgentInstructionsBoundEvent round-trips as polymorphic EventPayload`() { + val sample: EventPayload = AgentInstructionsBoundEvent( + sessionId = SessionId("s1"), + workspaceRoot = "/repo", + sources = listOf("CLAUDE.md", "AGENTS.md"), + content = "# CLAUDE.md\n\nbe careful\n\n# AGENTS.md\n\nuse tools", + ) + val encoded = eventJson.encodeToString(EventPayload.serializer(), sample) + assertTrue(encoded.contains("\"type\":\"AgentInstructionsBound\""), "SerialName must be present: $encoded") + val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded) + assertEquals(sample, decoded) + } + @Test fun `round-trips with empty fields`() { val sample: EventPayload = ProjectProfileBoundEvent( diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/ContextFeedback.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/ContextFeedback.kt index 5fcab52e..4557a00d 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/ContextFeedback.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/ContextFeedback.kt @@ -15,6 +15,7 @@ import com.correx.core.events.events.StoredEvent import com.correx.core.events.types.ArtifactId import com.correx.core.events.types.ContextEntryId import com.correx.core.events.types.StageId +import com.correx.core.sessions.BoundAgentInstructions import com.correx.core.sessions.BoundProjectProfile import com.correx.core.transitions.graph.WorkflowGraph import java.util.UUID @@ -153,3 +154,17 @@ fun buildProjectProfileEntry(profile: BoundProjectProfile): ContextEntry { role = EntryRole.SYSTEM, ) } + +/** Renders bound CLAUDE.md / AGENTS.md instructions as a single L0 system entry. */ +fun buildAgentInstructionsEntry(instructions: BoundAgentInstructions): ContextEntry { + val content = instructions.content + return ContextEntry( + id = ContextEntryId(UUID.randomUUID().toString()), + layer = ContextLayer.L0, + content = content, + sourceType = "agentInstructions", + sourceId = "agent-instructions", + tokenEstimate = content.length / 4, + role = EntryRole.SYSTEM, + ) +} diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt index e0960b82..7be575a9 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt @@ -399,6 +399,8 @@ abstract class SessionOrchestrator( } ?: emptyList() val projectProfileEntries = session.state.boundProjectProfile ?.let { listOf(buildProjectProfileEntry(it)) } ?: emptyList() + val agentInstructionsEntries = session.state.boundAgentInstructions + ?.let { listOf(buildAgentInstructionsEntry(it)) } ?: emptyList() val retryFeedbackEntries = buildRetryFeedbackEntry(sessionEvents, stageId) ?.let { listOf(it) } ?: emptyList() val vocabularyEntries = artifactKindRegistry @@ -411,7 +413,8 @@ abstract class SessionOrchestrator( .mapNotNull { it.payload as? StaticFindingsRecordedEvent } .flatMap { it.findings } var accumulatedEntries = excludeStaticFindingsFromReview( - systemPrompt + profileEntries + projectProfileEntries + journalEntries + repoMapEntries + + systemPrompt + profileEntries + projectProfileEntries + agentInstructionsEntries + + journalEntries + repoMapEntries + needsEntries + schemaEntries + vocabularyEntries + promptEntries + steeringEntries + clarificationEntries + retryFeedbackEntries, recordedStaticFindings, diff --git a/core/sessions/src/main/kotlin/com/correx/core/sessions/BoundAgentInstructions.kt b/core/sessions/src/main/kotlin/com/correx/core/sessions/BoundAgentInstructions.kt new file mode 100644 index 00000000..cb5db1a0 --- /dev/null +++ b/core/sessions/src/main/kotlin/com/correx/core/sessions/BoundAgentInstructions.kt @@ -0,0 +1,7 @@ +package com.correx.core.sessions + +/** Snapshot of standing agent instructions (CLAUDE.md / AGENTS.md) bound at session start. */ +data class BoundAgentInstructions( + val sources: List, + val content: String, +) diff --git a/core/sessions/src/main/kotlin/com/correx/core/sessions/DefaultSessionReducer.kt b/core/sessions/src/main/kotlin/com/correx/core/sessions/DefaultSessionReducer.kt index d6622dbd..32ad5e84 100644 --- a/core/sessions/src/main/kotlin/com/correx/core/sessions/DefaultSessionReducer.kt +++ b/core/sessions/src/main/kotlin/com/correx/core/sessions/DefaultSessionReducer.kt @@ -1,5 +1,6 @@ package com.correx.core.sessions +import com.correx.core.events.events.AgentInstructionsBoundEvent import com.correx.core.events.events.OperatorProfileBoundEvent import com.correx.core.events.events.ProjectProfileBoundEvent import com.correx.core.events.events.SessionWorkspaceBoundEvent @@ -61,6 +62,14 @@ class DefaultSessionReducer : SessionReducer { else -> state.boundProjectProfile } + val boundAgentInstructions = when (payload) { + is AgentInstructionsBoundEvent -> BoundAgentInstructions( + sources = payload.sources, + content = payload.content, + ) + else -> state.boundAgentInstructions + } + return state.copy( status = newStatus, createdAt = createdAt, @@ -68,6 +77,7 @@ class DefaultSessionReducer : SessionReducer { boundWorkspace = boundWorkspace, boundProfile = boundProfile, boundProjectProfile = boundProjectProfile, + boundAgentInstructions = boundAgentInstructions, ) } } diff --git a/core/sessions/src/main/kotlin/com/correx/core/sessions/SessionState.kt b/core/sessions/src/main/kotlin/com/correx/core/sessions/SessionState.kt index d2d21f48..4d28ef3a 100644 --- a/core/sessions/src/main/kotlin/com/correx/core/sessions/SessionState.kt +++ b/core/sessions/src/main/kotlin/com/correx/core/sessions/SessionState.kt @@ -10,4 +10,5 @@ data class SessionState( val boundWorkspace: BoundWorkspace? = null, val boundProfile: BoundProfile? = null, val boundProjectProfile: BoundProjectProfile? = null, + val boundAgentInstructions: BoundAgentInstructions? = null, ) diff --git a/testing/kernel/src/test/kotlin/ContextFeedbackTest.kt b/testing/kernel/src/test/kotlin/ContextFeedbackTest.kt index 4de53ce9..85b9ccae 100644 --- a/testing/kernel/src/test/kotlin/ContextFeedbackTest.kt +++ b/testing/kernel/src/test/kotlin/ContextFeedbackTest.kt @@ -11,11 +11,13 @@ import com.correx.core.events.types.SessionId import com.correx.core.events.types.StageId import com.correx.core.events.types.TransitionId import com.correx.core.events.events.RepoKnowledgeHit +import com.correx.core.kernel.orchestration.buildAgentInstructionsEntry import com.correx.core.kernel.orchestration.buildArtifactKindVocabularyEntry import com.correx.core.kernel.orchestration.buildProjectProfileEntry import com.correx.core.kernel.orchestration.buildRelevantFilesEntry import com.correx.core.kernel.orchestration.buildRetryFeedbackEntry import com.correx.core.kernel.orchestration.criticArtifactIds +import com.correx.core.sessions.BoundAgentInstructions import com.correx.core.sessions.BoundProjectProfile import com.correx.core.transitions.graph.StageConfig import com.correx.core.transitions.graph.TransitionEdge @@ -87,6 +89,21 @@ class ContextFeedbackTest { assertEquals("projectProfile", entry.sourceType) } + @Test + fun `agent instructions render as single L0 entry`() { + val entry = buildAgentInstructionsEntry( + BoundAgentInstructions( + sources = listOf("CLAUDE.md", "AGENTS.md"), + content = "# CLAUDE.md\n\nbe careful\n\n# AGENTS.md\n\nuse tools", + ), + ) + assertEquals(ContextLayer.L0, entry.layer) + assertEquals(EntryRole.SYSTEM, entry.role) + assertEquals("agentInstructions", entry.sourceType) + assertTrue(entry.content.contains("# CLAUDE.md"), "content: ${entry.content}") + assertTrue(entry.content.contains("# AGENTS.md"), "content: ${entry.content}") + } + @Test fun `criticArtifactIds resolves the from-stage produces on a back-edge`() { val graph = graphWith( diff --git a/testing/projections/src/test/kotlin/DefaultSessionReducerTest.kt b/testing/projections/src/test/kotlin/DefaultSessionReducerTest.kt index 878d367c..0b4ac707 100644 --- a/testing/projections/src/test/kotlin/DefaultSessionReducerTest.kt +++ b/testing/projections/src/test/kotlin/DefaultSessionReducerTest.kt @@ -10,7 +10,9 @@ import com.correx.core.events.events.WorkflowStartedEvent import com.correx.core.events.types.SessionId import com.correx.core.events.types.StageId import com.correx.core.events.types.TransitionId +import com.correx.core.events.events.AgentInstructionsBoundEvent import com.correx.core.events.events.ProjectProfileBoundEvent +import com.correx.core.sessions.BoundAgentInstructions import com.correx.core.sessions.BoundProjectProfile import com.correx.core.sessions.BoundWorkspace import com.correx.core.sessions.DefaultSessionReducer @@ -327,6 +329,50 @@ class DefaultSessionReducerTest { assertEquals("kernel project", result.boundProjectProfile?.about) } + @Test + fun `AgentInstructionsBoundEvent reduces into boundAgentInstructions`() { + val result = reducer.reduce( + state = initialState(), + event = stored( + sessionId = sessionId, + payload = AgentInstructionsBoundEvent( + sessionId = sessionId, + workspaceRoot = "/repo", + sources = listOf("CLAUDE.md", "AGENTS.md"), + content = "# CLAUDE.md\n\nbe careful", + ), + ), + ) + + assertEquals( + BoundAgentInstructions( + sources = listOf("CLAUDE.md", "AGENTS.md"), + content = "# CLAUDE.md\n\nbe careful", + ), + result.boundAgentInstructions, + ) + } + + @Test + fun `boundAgentInstructions is preserved by subsequent unrelated events`() { + val withInstructions = initialState().copy( + boundAgentInstructions = BoundAgentInstructions( + sources = listOf("CLAUDE.md"), + content = "# CLAUDE.md\n\nrules", + ), + ) + + val result = reducer.reduce( + state = withInstructions, + event = stored( + sessionId = sessionId, + payload = WorkflowStartedEvent(sessionId, workflowId = "wf", startStageId = StageId("s1")), + ), + ) + + assertEquals(listOf("CLAUDE.md"), result.boundAgentInstructions?.sources) + } + private fun initialState() = SessionState( status = SessionStatus.CREATED From 54f94a549f079021dabf1112e6569f8d797471e9 Mon Sep 17 00:00:00 2001 From: kami Date: Sun, 21 Jun 2026 10:50:28 +0000 Subject: [PATCH 044/107] feat(tui-go): command-palette keybinds + configurable status bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Palette: each command now shows its bare-key shortcut in a key column (and the filter matches on it), so the palette teaches the shortcuts instead of hiding them. Status bar: a new "status bar" palette command opens a toggle overlay to show/hide the non-essential segments (current stage, session status, workspace path, model, last-event clock, resource gauge); correx/connection/session-name/spinner stay. Choices persist TUI-local in tui-prefs.json (JSON in the config dir — display state, kept out of the shared/replayed server config) and reload on launch. Also trims the in-session footer (drops stats/model — both reachable via `p cmds`) so it no longer overflows + truncates `q quit` at ~100 cols after the transcript-nav additions. Tests cover toggle→render gating and prefs persistence; verified via cmd/preview renders. Co-Authored-By: Claude Opus 4.8 --- apps/tui-go/internal/app/model.go | 15 ++-- apps/tui-go/internal/app/overlays.go | 40 ++++++++++- apps/tui-go/internal/app/prefs.go | 80 +++++++++++++++++++++ apps/tui-go/internal/app/status_bar_test.go | 57 +++++++++++++++ apps/tui-go/internal/app/update.go | 68 ++++++++++++++---- apps/tui-go/internal/app/view.go | 38 +++++----- 6 files changed, 261 insertions(+), 37 deletions(-) create mode 100644 apps/tui-go/internal/app/prefs.go create mode 100644 apps/tui-go/internal/app/status_bar_test.go diff --git a/apps/tui-go/internal/app/model.go b/apps/tui-go/internal/app/model.go index 9da2dc0c..f3a733a4 100644 --- a/apps/tui-go/internal/app/model.go +++ b/apps/tui-go/internal/app/model.go @@ -58,6 +58,7 @@ const ( OverlayIdeas OverlaySessions OverlayFiles + OverlayStatusbar ) // RouterEntry is one line in a session's conversation transcript. @@ -188,11 +189,11 @@ type Session struct { // PendingQueue[PendingIdx] (kept in sync by syncPending) so the render code can // keep reading a single pointer while multiple gates queue up behind it. Pending *Approval - PendingQueue []*Approval // all pending gates, oldest first; navigated with ↑/↓ - PendingIdx int // selected index into PendingQueue + PendingQueue []*Approval // all pending gates, oldest first; navigated with ↑/↓ + PendingIdx int // selected index into PendingQueue Clar *Clarification // open questions awaiting answers (clarification view) - Propose *Proposal // router workflow suggestion awaiting a pick (propose view) - Active bool // an inference/tool is in flight (drives the spinner) + Propose *Proposal // router workflow suggestion awaiting a pick (propose view) + Active bool // an inference/tool is in flight (drives the spinner) } // Workflow is a launchable workflow advertised by the server. @@ -314,6 +315,11 @@ type Model struct { fileFilter string // the query typed after @ (narrows the list) fileIndex int + // status-bar segment visibility (OverlayStatusbar) — persisted TUI-local in tui-prefs.json. + // A segment id present in sbHidden is hidden; absent = shown. sbIndex is the toggle cursor. + sbHidden map[string]bool + sbIndex int + // animation frame int // tick counter; drives spinner + caret blink ticking bool // true while a tick loop is scheduled. The loop is gated on animating() @@ -367,6 +373,7 @@ func NewModel(client *ws.Client) Model { snapshotPhase: true, eventStripShown: true, transcriptSel: -1, + sbHidden: loadStatusbarHidden(), } } diff --git a/apps/tui-go/internal/app/overlays.go b/apps/tui-go/internal/app/overlays.go index 12d55146..832938b6 100644 --- a/apps/tui-go/internal/app/overlays.go +++ b/apps/tui-go/internal/app/overlays.go @@ -126,6 +126,8 @@ func (m Model) renderOverlay(base string) string { return m.center(m.sessionsModal()) case OverlayFiles: return m.center(m.filesModal()) + case OverlayStatusbar: + return m.center(m.statusbarModal()) } return base } @@ -513,8 +515,13 @@ func (m Model) paletteModal() string { marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▌ ") titleFg = t.P.FgStrong } - b.WriteString(marker + - lipgloss.NewStyle().Foreground(titleFg).Background(t.P.BgPanel).Render(padRaw(c.title, 18)) + + // keybind column: the bare-key shortcut, so the palette teaches the shortcuts. + keyCell := mbg(t, padRaw("", 4), t.P.BgPanel) + if c.key != "" { + keyCell = lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Bold(true).Render(padRaw(c.key, 4)) + } + b.WriteString(marker + keyCell + + lipgloss.NewStyle().Foreground(titleFg).Background(t.P.BgPanel).Render(padRaw(c.title, 16)) + mbg(t, " "+c.hint, t.P.Faint) + "\n") } b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "select"}, {"enter", "run"}, {"esc", "close"}})) @@ -579,6 +586,35 @@ func (m Model) filesModal() string { return m.center(modal) } +// statusbarModal toggles which status-bar segments are shown. Choices persist to the local +// tui-prefs.json so the bar stays as configured across launches. +func (m Model) statusbarModal() string { + t := m.theme + w := m.modalWidth() + + var b strings.Builder + b.WriteString(m.titleLine("status bar") + "\n\n") + b.WriteString(mbg(t, " show / hide segments (correx, connection, session name stay)", t.P.Faint) + "\n\n") + + for i, seg := range statusSegments { + marker := mbg(t, " ", t.P.BgPanel) + labelFg := t.P.Fg + if i == m.sbIndex { + marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▌ ") + labelFg = t.P.FgStrong + } + box := lipgloss.NewStyle().Foreground(t.P.OK).Background(t.P.BgPanel).Render("[✓] ") + if !m.sbShow(seg.id) { + box = mbg(t, "[ ] ", t.P.Faint) + } + b.WriteString(marker + box + + lipgloss.NewStyle().Foreground(labelFg).Background(t.P.BgPanel).Render(seg.label) + "\n") + } + b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "move"}, {"space", "toggle"}, {"esc", "close"}})) + modal := t.Overlay.Width(w).Render(b.String()) + return m.center(modal) +} + func (m Model) toolPaletteModal() string { t := m.theme w := m.modalWidth() diff --git a/apps/tui-go/internal/app/prefs.go b/apps/tui-go/internal/app/prefs.go new file mode 100644 index 00000000..550616da --- /dev/null +++ b/apps/tui-go/internal/app/prefs.go @@ -0,0 +1,80 @@ +package app + +import ( + "encoding/json" + "os" + "path/filepath" +) + +// prefs is the TUI's local, persisted preferences (display-only client state — kept out +// of the server config, which is shared/replayed). Stored as JSON in the correx config dir. +type prefs struct { + StatusbarHidden []string `json:"statusbarHidden"` +} + +// statusSegment is one toggleable status-bar segment. The always-on segments (correx label, +// connection, session name, spinner, background-updates) are not listed — they are load-bearing. +type statusSegment struct{ id, label string } + +var statusSegments = []statusSegment{ + {"stage", "current stage (⟐)"}, + {"status", "session status"}, + {"workspace", "workspace path (⌂)"}, + {"model", "model name"}, + {"clock", "last-event clock"}, + {"gauge", "resource gauge"}, +} + +func prefsPath() string { + dir := os.Getenv("CORREX_CONFIG_HOME") + if dir == "" { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + dir = filepath.Join(home, ".config", "correx") + } + return filepath.Join(dir, "tui-prefs.json") +} + +// loadStatusbarHidden reads the persisted hidden-segment set (best-effort: a missing or +// corrupt file yields an empty set, i.e. everything shown). +func loadStatusbarHidden() map[string]bool { + out := map[string]bool{} + path := prefsPath() + if path == "" { + return out + } + data, err := os.ReadFile(path) + if err != nil { + return out + } + var p prefs + if json.Unmarshal(data, &p) == nil { + for _, id := range p.StatusbarHidden { + out[id] = true + } + } + return out +} + +// saveStatusbarHidden writes the hidden set back to disk (best-effort; ignores IO errors so +// a read-only home never crashes the UI). Order follows statusSegments for a stable file. +func saveStatusbarHidden(hidden map[string]bool) { + path := prefsPath() + if path == "" { + return + } + var ids []string + for _, seg := range statusSegments { + if hidden[seg.id] { + ids = append(ids, seg.id) + } + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return + } + if data, err := json.MarshalIndent(prefs{StatusbarHidden: ids}, "", " "); err == nil { + _ = os.WriteFile(path, data, 0o644) + } +} diff --git a/apps/tui-go/internal/app/status_bar_test.go b/apps/tui-go/internal/app/status_bar_test.go new file mode 100644 index 00000000..c42aa053 --- /dev/null +++ b/apps/tui-go/internal/app/status_bar_test.go @@ -0,0 +1,57 @@ +package app + +import ( + "strings" + "testing" +) + +// Toggling a segment hides it from the rendered status bar; toggling again restores it. +// Reuses inSessionModel (adaptive_layout_test.go): stage "write_script", a long model id. +func TestStatusBarToggleHidesSegment(t *testing.T) { + t.Setenv("CORREX_CONFIG_HOME", t.TempDir()) // isolate persistence + m := inSessionModel(220, 30) + m.sbHidden = map[string]bool{} // start from a clean prefs state + + if !strings.Contains(m.renderStatus(), "write_script") { + t.Fatal("stage should be visible by default") + } + m.toggleStatusSegment("stage") + if strings.Contains(m.renderStatus(), "write_script") { + t.Fatal("stage should be hidden after toggle") + } + if !strings.Contains(m.renderStatus(), "llama-cpp") { + t.Fatal("model should still be visible (only stage was hidden)") + } + m.toggleStatusSegment("stage") + if !strings.Contains(m.renderStatus(), "write_script") { + t.Fatal("stage should be visible again after second toggle") + } +} + +func TestStatusBarModelHide(t *testing.T) { + t.Setenv("CORREX_CONFIG_HOME", t.TempDir()) + m := inSessionModel(220, 30) + m.sbHidden = map[string]bool{} + m.toggleStatusSegment("model") + if strings.Contains(m.renderStatus(), "llama-cpp") { + t.Fatal("model should be hidden after toggle") + } +} + +// A toggle persists to tui-prefs.json and is reloaded on the next launch. +func TestStatusBarPrefsPersist(t *testing.T) { + t.Setenv("CORREX_CONFIG_HOME", t.TempDir()) + m := inSessionModel(220, 30) + m.sbHidden = map[string]bool{} + + m.toggleStatusSegment("gauge") + m.toggleStatusSegment("clock") + + reloaded := loadStatusbarHidden() + if !reloaded["gauge"] || !reloaded["clock"] { + t.Fatalf("persisted hidden set = %v, want gauge+clock hidden", reloaded) + } + if reloaded["stage"] { + t.Fatal("stage was never toggled; should not be persisted as hidden") + } +} diff --git a/apps/tui-go/internal/app/update.go b/apps/tui-go/internal/app/update.go index 2dfd3b04..c54de861 100644 --- a/apps/tui-go/internal/app/update.go +++ b/apps/tui-go/internal/app/update.go @@ -731,10 +731,43 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { m.fileFilter += string(k.Runes) m.fileIndex = 0 } + case OverlayStatusbar: + switch { + case k.Type == tea.KeyUp || runeIs(k, "k"): + if m.sbIndex > 0 { + m.sbIndex-- + } + case k.Type == tea.KeyDown || runeIs(k, "j"): + if m.sbIndex < len(statusSegments)-1 { + m.sbIndex++ + } + case k.Type == tea.KeySpace || k.Type == tea.KeyEnter || runeIs(k, "x"): + if m.sbIndex >= 0 && m.sbIndex < len(statusSegments) { + m.toggleStatusSegment(statusSegments[m.sbIndex].id) + } + case runeIs(k, "g"): + m.overlay = OverlayNone + } } return m, nil } +// toggleStatusSegment flips a status-bar segment's visibility and persists the change. +func (m *Model) toggleStatusSegment(id string) { + if m.sbHidden == nil { + m.sbHidden = map[string]bool{} + } + if m.sbHidden[id] { + delete(m.sbHidden, id) + } else { + m.sbHidden[id] = true + } + saveStatusbarHidden(m.sbHidden) +} + +// sbShow reports whether a status-bar segment is currently visible. +func (m Model) sbShow(id string) bool { return !m.sbHidden[id] } + // openArtifacts opens the artifact viewer for the selected session and requests its // artifacts from the server. No-op when no session is selected. func (m *Model) openArtifacts() { @@ -821,22 +854,26 @@ func (m Model) handlePaletteKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } -type paletteCmd struct{ id, title, hint string } +// paletteCmd is one command-palette row. key is the bare-key shortcut that runs the same +// action directly (shown in the palette so the shortcuts are discoverable); empty when the +// command has no direct key. +type paletteCmd struct{ id, key, title, hint string } func paletteCommands() []paletteCmd { return []paletteCmd{ - {"workflows", "start workflow", "open the workflow picker"}, - {"tools", "tool palette", "tools for the current stage"}, - {"models", "swap model", "pick / pin the local model"}, - {"events", "event inspector", "browse the event stream"}, - {"artifacts", "view artifacts", "browse this session's artifacts"}, - {"stats", "session stats", "metrics for the selected session"}, - {"sessions", "resume session", "browse / resume recent sessions"}, - {"config", "edit config", "view / change correx settings"}, - {"mode", "toggle mode", "switch chat / steering"}, - {"cancel", "cancel session", "stop the selected session"}, - {"back", "back to list", "leave the current session"}, - {"quit", "quit", "exit correx"}, + {"workflows", "w", "start workflow", "open the workflow picker"}, + {"tools", "t", "tool palette", "tools for the current stage"}, + {"models", "m", "swap model", "pick / pin the local model"}, + {"events", "e", "event inspector", "browse the event stream"}, + {"artifacts", "v", "view artifacts", "browse this session's artifacts"}, + {"stats", "S", "session stats", "metrics for the selected session"}, + {"sessions", "R", "resume session", "browse / resume recent sessions"}, + {"config", "g", "edit config", "view / change correx settings"}, + {"statusbar", "", "status bar", "show / hide status-bar segments"}, + {"mode", "s", "toggle mode", "switch chat / steering"}, + {"cancel", "c", "cancel session", "stop the selected session"}, + {"back", "l", "back to list", "leave the current session"}, + {"quit", "q", "quit", "exit correx"}, } } @@ -847,7 +884,7 @@ func (m Model) filteredPalette() []paletteCmd { } var out []paletteCmd for _, c := range paletteCommands() { - if strings.Contains(strings.ToLower(c.title+" "+c.hint), f) { + if strings.Contains(strings.ToLower(c.key+" "+c.title+" "+c.hint), f) { out = append(out, c) } } @@ -875,6 +912,9 @@ func (m Model) execPalette(id string) (tea.Model, tea.Cmd) { return m, m.openSessions() case "config": m.openConfig() + case "statusbar": + m.overlay = OverlayStatusbar + m.sbIndex = 0 case "mode": m.cycleChatMode() case "cancel": diff --git a/apps/tui-go/internal/app/view.go b/apps/tui-go/internal/app/view.go index ec977b75..2c81307b 100644 --- a/apps/tui-go/internal/app/view.go +++ b/apps/tui-go/internal/app/view.go @@ -95,29 +95,31 @@ func (m Model) renderStatus() string { if s := m.session(m.selectedID); s != nil && m.displayState() != StateIdle { parts = append(parts, span(s.Name, t.P.FgStrong)) - if s.CurrentStage != "" { + if s.CurrentStage != "" && m.sbShow("stage") { parts = append(parts, span("⟐ "+s.CurrentStage, t.P.Accent2)) } - if s.Status != "" { + if s.Status != "" && m.sbShow("status") { parts = append(parts, lipgloss.NewStyle().Foreground(statusColor(t, s.Status)).Background(bg).Render(statusLabel(s.Status))) } - if s.WorkspaceRoot != "" && !nrw { + if s.WorkspaceRoot != "" && !nrw && m.sbShow("workspace") { parts = append(parts, span("⌂ "+shortPath(s.WorkspaceRoot), t.P.Faint)) } } - model := m.currentModel - if model == "" { - model = "no model" - } - if nrw { - parts = append(parts, span(model, t.P.Fg)) - } else { - loc := "local" - if m.providerType == "REMOTE" { - loc = "remote" + if m.sbShow("model") { + model := m.currentModel + if model == "" { + model = "no model" + } + if nrw { + parts = append(parts, span(model, t.P.Fg)) + } else { + loc := "local" + if m.providerType == "REMOTE" { + loc = "remote" + } + parts = append(parts, span(model, t.P.Fg)+span(" ("+loc+")", t.P.Faint)) } - parts = append(parts, span(model, t.P.Fg)+span(" ("+loc+")", t.P.Faint)) } left := strings.Join(parts, sep) @@ -125,7 +127,7 @@ func (m Model) renderStatus() string { // Last-event clock (§2): always visible in-session, so a silent agent is never // mistaken for a healthy one. Updates every frame tick; turns warn-colored when an // active session has gone quiet past the stale threshold. - if s := m.session(m.selectedID); s != nil && m.displayState() != StateIdle { + if s := m.session(m.selectedID); s != nil && m.displayState() != StateIdle && m.sbShow("clock") { gap := nowMillis() - s.LastEventAt clockFg := t.P.Faint if s.Active && gap > staleEventThresholdMs { @@ -137,7 +139,7 @@ func (m Model) renderStatus() string { } right = append(right, span(label, clockFg)) } - if g := m.gaugeText(); g != "" && !nrw { + if g := m.gaugeText(); g != "" && !nrw && m.sbShow("gauge") { right = append(right, span(g, t.P.Faint)) } if s := m.session(m.selectedID); s != nil && s.Active { @@ -249,7 +251,9 @@ func (m Model) renderFooter() string { if nrw { hints = []string{hint("i", "msg"), hint("^↑↓", "msgs"), hint("y", "copy"), hint("e", "events"), hint("l", "back"), hint("p", "cmds"), hint("q", "quit")} } else { - hints = []string{hint("i", "message"), hint("^↑↓", "msgs"), hint("y", "copy"), hint("e", "events"), hint("t", "tools"), hint("S", "stats"), hint("m", "model"), hint("l", "back"), hint("p", "cmds"), hint("q", "quit")} + // Trimmed to fit ~100 cols: tools/stats/model are all reachable via `p cmds`, + // so the footer keeps the high-traffic keys + the new transcript nav/copy. + hints = []string{hint("i", "message"), hint("^↑↓", "msgs"), hint("y", "copy"), hint("e", "events"), hint("t", "tools"), hint("l", "back"), hint("p", "cmds"), hint("q", "quit")} } if m.copiedFlash != 0 && m.frame-m.copiedFlash < copiedFlashFrames { hints = append(hints, lipgloss.NewStyle().Foreground(t.P.OK).Background(bg).Bold(true).Render("✓ copied")) From c36d41b9d5635934e0076a5426e1e2d5ffbc4de9 Mon Sep 17 00:00:00 2001 From: kami Date: Sun, 21 Jun 2026 16:07:33 +0000 Subject: [PATCH 045/107] feat(approvals): cross-session grant scopes (PROJECT/GLOBAL) + revoke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the grant system's wider scopes real and add a revoke producer. Before: grants were loaded per-session (the gate folds only the running session's own stream), so GrantScope.PROJECT — though declared and matched in the engine — was dead in practice, and there was no GLOBAL scope or any way to revoke a grant (ApprovalGrantExpiredEvent had no producer). - GrantScope: add GLOBAL(toolName); make PROJECT tool-bound. Every operator-creatable scope is now tool-bound so a grant can never be a blanket "approve everything" (that's YOLO mode). - DefaultApprovalEngine.scopeMatches: GLOBAL matches the bound tool in any context; PROJECT matches when the session's projectId AND tool match. - Cross-session ledger: PROJECT/GLOBAL grants are appended to a reserved GRANT_LEDGER_SESSION_ID stream instead of a session's. The approval gate now unions the ledger's grants with the session's, and derives projectId from the bound workspace root (ProjectIdentity.of) so PROJECT grants match later sessions on the same repo. SESSION/STAGE grants still live in (and die with) their session. - Revoke: ClientMessage.RevokeGrant appends ApprovalGrantExpiredEvent to the ledger (reducer already drops it); ListGrants/GrantList expose the active standing grants for the TUI viewer. - Drop the server-side T2 grant ceiling per operator request: a grant may now authorize any tier (incl. destructive T3/T4). Tool-binding is retained as the remaining guard. Backend only; the TUI scope picker + grants viewer follow. core:events, core:approvals, core:kernel, apps:server compile; approvals/events/kernel/ server suites green; new GrantScopeMatchingTest (5) covers GLOBAL/PROJECT match, project isolation, and the no-cap T4 path. Co-Authored-By: Claude Opus 4.8 --- .../apps/server/protocol/ClientMessage.kt | 18 +++- .../com/correx/apps/server/protocol/Dtos.kt | 17 ++++ .../apps/server/protocol/ServerMessage.kt | 12 +++ .../apps/server/ws/GlobalStreamHandler.kt | 83 ++++++++++++---- .../correx/apps/server/ws/StreamQueries.kt | 51 ++++++++++ .../approvals/domain/DefaultApprovalEngine.kt | 19 ++-- .../domain/GrantScopeMatchingTest.kt | 99 +++++++++++++++++++ .../com/correx/core/approvals/GrantLedger.kt | 31 ++++++ .../com/correx/core/approvals/GrantScope.kt | 11 ++- .../orchestration/SessionOrchestrator.kt | 13 ++- 10 files changed, 323 insertions(+), 31 deletions(-) create mode 100644 core/approvals/src/test/kotlin/com/correx/core/approvals/domain/GrantScopeMatchingTest.kt create mode 100644 core/events/src/main/kotlin/com/correx/core/approvals/GrantLedger.kt diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ClientMessage.kt b/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ClientMessage.kt index b7d2fdc6..86edfb49 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ClientMessage.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ClientMessage.kt @@ -5,6 +5,7 @@ import com.correx.core.approvals.Tier import com.correx.core.events.events.ClarificationAnswer import com.correx.core.events.types.ApprovalRequestId import com.correx.core.events.types.ClarificationRequestId +import com.correx.core.events.types.GrantId import com.correx.core.events.types.SessionId import com.correx.core.events.types.StageId import com.correx.core.router.ChatMode @@ -21,6 +22,11 @@ enum class ApprovalDecision { enum class GrantScopeDto { SESSION, STAGE, + // Cross-session scopes (BACKLOG §grants). PROJECT auto-approves the bound tool for any session + // on the same workspace; GLOBAL for every session on this machine. Both are tool-bound and live + // in the cross-session grant ledger rather than a single session's stream. + PROJECT, + GLOBAL, } @Serializable @@ -100,10 +106,20 @@ sealed class ClientMessage { val permittedTiers: List, val reason: String, val expiresAt: Instant? = null, - // Required for SESSION scope: binds this grant to a specific tool name. + // Required for SESSION/PROJECT/GLOBAL scope: binds this grant to a specific tool name. + // The projectId for a PROJECT grant is derived server-side from the session's bound + // workspace, never trusted from the client. val toolName: String? = null, ) : ClientMessage() + /** Operator request to revoke a standing (PROJECT/GLOBAL) grant by id; reply is a fresh GrantList. */ + @Serializable + data class RevokeGrant(val grantId: GrantId) : ClientMessage() + + /** Operator request for the active cross-session grants (replied to with a GrantList). */ + @Serializable + data object ListGrants : ClientMessage() + @Serializable data class Ping(val timestamp: Long) : ClientMessage() diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/protocol/Dtos.kt b/apps/server/src/main/kotlin/com/correx/apps/server/protocol/Dtos.kt index bbd16985..1b3871de 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/protocol/Dtos.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/protocol/Dtos.kt @@ -121,6 +121,23 @@ data class IdeaDto( val capturedAtMs: Long, ) +/** + * One standing cross-session grant for the TUI grants viewer. [scope] is "PROJECT" or "GLOBAL"; + * [tiers] are the tier names this grant auto-approves; [projectId] is the workspace path a PROJECT + * grant is bound to (null for GLOBAL); [expiresAtMs] is the epoch-ms expiry, or null if it never + * expires. + */ +@Serializable +data class GrantDto( + val grantId: String, + val scope: String, + val toolName: String?, + val projectId: String?, + val tiers: List, + val reason: String, + val expiresAtMs: Long?, +) + internal fun RiskSummary.toDto(): RiskSummaryDto = RiskSummaryDto( level = level.name, factors = signals.map { signal -> diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ServerMessage.kt b/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ServerMessage.kt index e840e213..e08d30a2 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ServerMessage.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ServerMessage.kt @@ -489,6 +489,18 @@ sealed interface ServerMessage { override val sessionSequence: Long? = null, ) : ServerMessage, NonEventMessage + /** + * The active cross-session (PROJECT/GLOBAL) grants (reply to ListGrants / CreateGrant / + * RevokeGrant). Not event-derived (a snapshot folded from the grant ledger), so cursors are null. + */ + @Serializable + @SerialName("grant.list") + data class GrantList( + val grants: List, + override val sequence: Long? = null, + override val sessionSequence: Long? = null, + ) : ServerMessage, NonEventMessage + /** * A session's derived metrics, returned in response to a GetSessionStats request. Not * event-derived (it is a projection-replay snapshot), so cursors are null. [stats] is the same diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/ws/GlobalStreamHandler.kt b/apps/server/src/main/kotlin/com/correx/apps/server/ws/GlobalStreamHandler.kt index b937dd06..4a618419 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/ws/GlobalStreamHandler.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/ws/GlobalStreamHandler.kt @@ -13,9 +13,10 @@ import com.correx.apps.server.protocol.WorkflowDto import com.correx.apps.server.protocol.StageToolDecl import com.correx.apps.server.protocol.ToolDecl import com.correx.apps.server.workspace.WorkspaceResolution +import com.correx.core.approvals.GRANT_LEDGER_SESSION_ID import com.correx.core.approvals.GrantScope -import com.correx.core.approvals.Tier import com.correx.core.events.events.ApprovalGrantCreatedEvent +import com.correx.core.events.events.ApprovalGrantExpiredEvent import com.correx.core.events.events.ChatSessionStartedEvent import com.correx.core.events.events.EventMetadata import com.correx.core.events.events.IdeaDiscardedEvent @@ -246,6 +247,8 @@ class GlobalStreamHandler(private val module: ServerModule) { is ClientMessage.ApprovalResponse -> handleApprovalResponse(msg, sendFrame) is ClientMessage.ClarificationResponse -> handleClarificationResponse(msg) is ClientMessage.CreateGrant -> handleCreateGrant(msg, sendFrame) + is ClientMessage.RevokeGrant -> handleRevokeGrant(msg, sendFrame) + is ClientMessage.ListGrants -> sendFrame(queries.listGrants()) is ClientMessage.ChatInput -> { // The USER and ROUTER turns are emitted as ChatTurnEvents inside onUserInput // and reach the client as chat.turn frames via streamGlobal — no direct @@ -402,18 +405,20 @@ class GlobalStreamHandler(private val module: ServerModule) { msg: ClientMessage.CreateGrant, sendFrame: suspend (ServerMessage) -> Unit, ) { - // Validate: tiers must be non-empty and bounded to T2 (server-side ceiling). - // T3/T4 are destructive/escalated tiers that must never be auto-approved via grants. + // No tier ceiling: the operator explicitly opted out of the prior T2 cap, so a grant may + // authorize any tier (including the destructive T3/T4). Every operator-creatable scope is + // still tool-bound (below) so a grant never becomes a blanket "approve everything". if (msg.permittedTiers.isEmpty()) { sendFrame(errorResponse("CreateGrant: permittedTiers must not be empty")) return } - val maxGrantableTier = Tier.T2 - val overBroad = msg.permittedTiers.filter { it.level > maxGrantableTier.level } - if (overBroad.isNotEmpty()) { - sendFrame(errorResponse("CreateGrant: tiers ${overBroad.map { it.name }} exceed maximum grantable tier ${maxGrantableTier.name}")) - return - } + + suspend fun requireToolName(scopeLabel: String): String? = + msg.toolName?.takeIf { it.isNotBlank() } + ?: run { + sendFrame(errorResponse("CreateGrant: $scopeLabel scope requires toolName to prevent blanket approval")) + null + } val scope = when (msg.scope) { GrantScopeDto.SESSION -> { @@ -421,12 +426,7 @@ class GlobalStreamHandler(private val module: ServerModule) { sendFrame(errorResponse("CreateGrant: SESSION scope must not include stageId")) return } - // Require toolName — blanket session grants (no operation scope) are rejected. - val toolName = msg.toolName?.takeIf { it.isNotBlank() } ?: run { - sendFrame(errorResponse("CreateGrant: SESSION scope requires toolName to prevent blanket approval")) - return - } - GrantScope.SESSION(toolName) + GrantScope.SESSION(requireToolName("SESSION") ?: return) } GrantScopeDto.STAGE -> { val sid = msg.stageId ?: run { @@ -435,11 +435,25 @@ class GlobalStreamHandler(private val module: ServerModule) { } GrantScope.STAGE(sid) } + GrantScopeDto.PROJECT -> { + // projectId is derived from the session's bound workspace, never trusted from the client. + val projectId = queries.projectIdForSession(msg.sessionId) ?: run { + sendFrame(errorResponse("CreateGrant: PROJECT scope requires a session with a bound workspace")) + return + } + GrantScope.PROJECT(projectId, requireToolName("PROJECT") ?: return) + } + GrantScopeDto.GLOBAL -> GrantScope.GLOBAL(requireToolName("GLOBAL") ?: return) } + + // SESSION/STAGE grants live in the originating session's stream (they die with it); PROJECT + // and GLOBAL grants outlive any session, so they go to the shared cross-session grant ledger. + val ledgered = scope is GrantScope.PROJECT || scope is GrantScope.GLOBAL + val streamId = if (ledgered) GRANT_LEDGER_SESSION_ID else msg.sessionId val event = NewEvent( metadata = EventMetadata( eventId = EventId(UUID.randomUUID().toString()), - sessionId = msg.sessionId, + sessionId = streamId, timestamp = Clock.System.now(), schemaVersion = 1, causationId = null, @@ -453,11 +467,44 @@ class GlobalStreamHandler(private val module: ServerModule) { expiresAt = msg.expiresAt, sessionId = msg.sessionId, stageId = (scope as? GrantScope.STAGE)?.stageId, - projectId = null, - toolName = (scope as? GrantScope.SESSION)?.toolName, + projectId = (scope as? GrantScope.PROJECT)?.projectId, + toolName = scope.toolNameOrNull(), ), ) module.eventStore.append(event) + // Echo the refreshed standing-grant list so the TUI can confirm a PROJECT/GLOBAL grant landed. + if (ledgered) sendFrame(queries.listGrants()) + } + + /** + * Revokes a standing (PROJECT/GLOBAL) grant by appending an [ApprovalGrantExpiredEvent] to the + * grant ledger — the reducer drops it, so the gate stops honouring it across all sessions while + * the create/revoke pair stays in the audit log. Replies with the refreshed grant list. + */ + private suspend fun handleRevokeGrant( + msg: ClientMessage.RevokeGrant, + sendFrame: suspend (ServerMessage) -> Unit, + ) { + val event = NewEvent( + metadata = EventMetadata( + eventId = EventId(UUID.randomUUID().toString()), + sessionId = GRANT_LEDGER_SESSION_ID, + timestamp = Clock.System.now(), + schemaVersion = 1, + causationId = null, + correlationId = null, + ), + payload = ApprovalGrantExpiredEvent(grantId = msg.grantId), + ) + module.eventStore.append(event) + sendFrame(queries.listGrants()) + } + + private fun GrantScope.toolNameOrNull(): String? = when (this) { + is GrantScope.SESSION -> toolName + is GrantScope.PROJECT -> toolName + is GrantScope.GLOBAL -> toolName + is GrantScope.STAGE -> null } private suspend fun handleStartChatSession( diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/ws/StreamQueries.kt b/apps/server/src/main/kotlin/com/correx/apps/server/ws/StreamQueries.kt index 82c7c94f..3d4e2b01 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/ws/StreamQueries.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/ws/StreamQueries.kt @@ -5,9 +5,14 @@ import com.correx.apps.server.config.ConfigUpdateResult import com.correx.apps.server.metrics.MetricsInspectionService import com.correx.apps.server.protocol.ArtifactSummaryDto import com.correx.apps.server.protocol.ConfigFieldDto +import com.correx.apps.server.protocol.GrantDto import com.correx.apps.server.protocol.IdeaDto import com.correx.apps.server.protocol.ServerMessage import com.correx.apps.server.workspace.WorkspaceFiles +import com.correx.core.approvals.GRANT_LEDGER_SESSION_ID +import com.correx.core.approvals.GrantScope +import com.correx.core.approvals.ProjectIdentity +import com.correx.core.events.types.ProjectId import com.correx.core.router.IdeaReader import com.correx.core.config.EditableConfig import com.correx.core.events.events.ArtifactContentStoredEvent @@ -21,6 +26,7 @@ import com.correx.core.events.types.ArtifactId import com.correx.core.events.types.SessionId import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import kotlinx.datetime.Clock import java.nio.file.Paths import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonPrimitive @@ -70,6 +76,51 @@ class StreamQueries(private val module: ServerModule) { .lastOrNull() ?.workspaceRoot + /** + * The stable [ProjectId] a session's bound workspace resolves to (the same derivation the + * approval gate uses), or null when the session has no bound workspace. Used to scope a PROJECT + * grant to the right repository without trusting a client-supplied id. + */ + fun projectIdForSession(sessionId: SessionId): ProjectId? = + workspaceRootOf(sessionId)?.let { ProjectIdentity.of(it) } + + /** + * The active cross-session grants as a snapshot frame, folded from the grant ledger via the + * approval reducer. Expired grants are dropped so the viewer only lists ones still in force. + * Pure read — no events appended (replay-neutral). + */ + fun listGrants(): ServerMessage.GrantList { + val now = Clock.System.now() + val grants = module.approvalRepository.getApprovalState(GRANT_LEDGER_SESSION_ID).grants.values + .filter { grant -> grant.expiresAt.let { it == null || it > now } } + .map { grant -> + GrantDto( + grantId = grant.id.value, + scope = grant.scope.kindName(), + toolName = grant.scope.toolNameOrNull(), + projectId = (grant.scope as? GrantScope.PROJECT)?.projectId?.value, + tiers = grant.permittedTiers.map { it.name }.sorted(), + reason = grant.reason, + expiresAtMs = grant.expiresAt?.toEpochMilliseconds(), + ) + } + return ServerMessage.GrantList(grants = grants) + } + + private fun GrantScope.kindName(): String = when (this) { + is GrantScope.SESSION -> "SESSION" + is GrantScope.STAGE -> "STAGE" + is GrantScope.PROJECT -> "PROJECT" + is GrantScope.GLOBAL -> "GLOBAL" + } + + private fun GrantScope.toolNameOrNull(): String? = when (this) { + is GrantScope.SESSION -> toolName + is GrantScope.PROJECT -> toolName + is GrantScope.GLOBAL -> toolName + is GrantScope.STAGE -> null + } + /** * Reads a session's events to assemble its artifact listing (creation order preserved), * resolving each artifact's stored bytes via the CAS artifact store. Pure snapshot read — diff --git a/core/approvals/src/main/kotlin/com/correx/core/approvals/domain/DefaultApprovalEngine.kt b/core/approvals/src/main/kotlin/com/correx/core/approvals/domain/DefaultApprovalEngine.kt index d62e1124..79de330f 100644 --- a/core/approvals/src/main/kotlin/com/correx/core/approvals/domain/DefaultApprovalEngine.kt +++ b/core/approvals/src/main/kotlin/com/correx/core/approvals/domain/DefaultApprovalEngine.kt @@ -87,13 +87,18 @@ class DefaultApprovalEngine : ApprovalEngine { private fun scopeMatches(scope: GrantScope, context: ApprovalContext, requestToolName: String?): Boolean = when (scope) { - // SESSION grants are scoped to a specific tool name. - // A null toolName on either side means the binding is absent; treat as no-match - // to prevent a legacy/malformed grant from becoming a blanket approval. - is GrantScope.SESSION -> scope.toolName != null - && requestToolName != null - && scope.toolName == requestToolName + // SESSION/PROJECT/GLOBAL grants are bound to a specific tool name. A null toolName on + // either side means the binding is absent; treat as no-match so a legacy/malformed grant + // can never become a blanket approval. PROJECT additionally requires the active session's + // workspace to resolve to the same projectId; GLOBAL applies to every session. + is GrantScope.SESSION -> toolBound(scope.toolName, requestToolName) is GrantScope.STAGE -> context.identity.stageId == scope.stageId - is GrantScope.PROJECT -> context.identity.projectId == scope.projectId + is GrantScope.PROJECT -> toolBound(scope.toolName, requestToolName) + && context.identity.projectId != null + && context.identity.projectId == scope.projectId + is GrantScope.GLOBAL -> toolBound(scope.toolName, requestToolName) } + + private fun toolBound(grantToolName: String?, requestToolName: String?): Boolean = + grantToolName != null && requestToolName != null && grantToolName == requestToolName } diff --git a/core/approvals/src/test/kotlin/com/correx/core/approvals/domain/GrantScopeMatchingTest.kt b/core/approvals/src/test/kotlin/com/correx/core/approvals/domain/GrantScopeMatchingTest.kt new file mode 100644 index 00000000..7d91a973 --- /dev/null +++ b/core/approvals/src/test/kotlin/com/correx/core/approvals/domain/GrantScopeMatchingTest.kt @@ -0,0 +1,99 @@ +package com.correx.core.approvals.domain + +import com.correx.core.approvals.ApprovalStatus +import com.correx.core.approvals.GrantScope +import com.correx.core.approvals.Tier +import com.correx.core.approvals.model.ApprovalContext +import com.correx.core.approvals.model.ApprovalGrant +import com.correx.core.approvals.model.ApprovalScopeIdentity +import com.correx.core.approvals.model.DomainApprovalRequest +import com.correx.core.events.types.ApprovalRequestId +import com.correx.core.events.types.GrantId +import com.correx.core.events.types.ProjectId +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.ValidationReportId +import com.correx.core.sessions.ApprovalMode +import kotlinx.datetime.Instant +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +/** + * Scope matching for the cross-session grant scopes (PROJECT, GLOBAL). A matching grant turns a + * request that would otherwise PROMPT into an AUTO_APPROVED COMPLETED decision; a non-match leaves + * it PENDING (PROMPT mode, tier above threshold). + */ +class GrantScopeMatchingTest { + + private val engine = DefaultApprovalEngine() + private val now = Instant.parse("2026-06-21T00:00:00Z") + private val projectA = ProjectId("/repo/a") + private val projectB = ProjectId("/repo/b") + + private fun request(tool: String, tier: Tier = Tier.T3) = DomainApprovalRequest( + id = ApprovalRequestId("req-1"), + tier = tier, + validationReportId = ValidationReportId("vr-1"), + riskSummaryId = null, + timestamp = now, + toolName = tool, + ) + + private fun context(projectId: ProjectId?) = ApprovalContext( + identity = ApprovalScopeIdentity(SessionId("s-1"), stageId = null, projectId = projectId), + mode = ApprovalMode.PROMPT, + ) + + private fun grant(scope: GrantScope, tiers: Set = setOf(Tier.T4)) = ApprovalGrant( + id = GrantId("g-1"), + scope = scope, + permittedTiers = tiers, + reason = "test", + timestamp = now, + ) + + private fun isAutoApproved(d: com.correx.core.approvals.model.ApprovalDecision) = + d.state == ApprovalStatus.COMPLETED && d.isApproved + + @Test + fun `GLOBAL grant auto-approves the bound tool in any project`() { + val g = grant(GrantScope.GLOBAL("write_file")) + val decision = engine.evaluate(request("write_file"), context(projectA), listOf(g), now) + assert(isAutoApproved(decision)) { "GLOBAL grant should auto-approve its tool anywhere" } + assertEquals("grant:g-1", decision.reason) + } + + @Test + fun `GLOBAL grant does not approve a different tool`() { + val g = grant(GrantScope.GLOBAL("write_file")) + val decision = engine.evaluate(request("shell"), context(projectA), listOf(g), now) + assertEquals(ApprovalStatus.PENDING, decision.state) + assertNull(decision.outcome) + } + + @Test + fun `PROJECT grant approves only the matching project`() { + val g = grant(GrantScope.PROJECT(projectA, "shell")) + val match = engine.evaluate(request("shell"), context(projectA), listOf(g), now) + assert(isAutoApproved(match)) { "PROJECT grant should approve in its own project" } + + val otherProject = engine.evaluate(request("shell"), context(projectB), listOf(g), now) + assertEquals(ApprovalStatus.PENDING, otherProject.state) + } + + @Test + fun `PROJECT grant does not approve when the session has no project identity`() { + val g = grant(GrantScope.PROJECT(projectA, "shell")) + val decision = engine.evaluate(request("shell"), context(projectId = null), listOf(g), now) + assertEquals(ApprovalStatus.PENDING, decision.state) + } + + @Test + fun `a wide grant with no tier ceiling auto-approves a T4 request`() { + // The operator opted out of the prior T2 cap: a grant whose permittedTiers reach T4 + // authorizes up to T4 (ceiling semantics), so even a destructive call auto-clears. + val g = grant(GrantScope.GLOBAL("delete"), tiers = setOf(Tier.T4)) + val decision = engine.evaluate(request("delete", tier = Tier.T4), context(projectA), listOf(g), now) + assert(isAutoApproved(decision)) { "T4 request should auto-approve under a T4-permitting grant" } + } +} diff --git a/core/events/src/main/kotlin/com/correx/core/approvals/GrantLedger.kt b/core/events/src/main/kotlin/com/correx/core/approvals/GrantLedger.kt new file mode 100644 index 00000000..4f93db21 --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/approvals/GrantLedger.kt @@ -0,0 +1,31 @@ +package com.correx.core.approvals + +import com.correx.core.events.types.ProjectId +import com.correx.core.events.types.SessionId +import java.nio.file.Paths + +/** + * Reserved event-store stream that holds cross-session approval grants (PROJECT and GLOBAL scopes). + * + * Session-scoped grants live in their own session's stream and die with it; project/global grants + * must outlive any single session and be visible to every other one, so they are appended here + * instead. The approval gate folds this ledger ([com.correx.core.approvals.DefaultApprovalReducer] + * over [DefaultApprovalRepository.getApprovalState]) and unions its grants with the running + * session's own before evaluating — see `SessionOrchestrator`. Revocation appends an + * [com.correx.core.events.events.ApprovalGrantExpiredEvent] to the same stream, which the reducer + * drops, so the audit trail stays intact (invariant #9: record facts, replay reads them). + * + * The id is a sentinel, not a real session; it never carries a workflow run. + */ +val GRANT_LEDGER_SESSION_ID = SessionId("__grant_ledger__") + +/** + * Derives a stable [ProjectId] from a workspace root path so PROJECT-scoped grants created in one + * session match later sessions opened on the same repository. Both the grant-creation site (server) + * and the approval gate (orchestrator) run in the same process, so canonicalising to a normalised + * absolute path yields the same id on both sides regardless of how the root was spelled. + */ +object ProjectIdentity { + fun of(workspaceRoot: String): ProjectId = + ProjectId(Paths.get(workspaceRoot).toAbsolutePath().normalize().toString()) +} diff --git a/core/events/src/main/kotlin/com/correx/core/approvals/GrantScope.kt b/core/events/src/main/kotlin/com/correx/core/approvals/GrantScope.kt index 93430a18..b0c63de7 100644 --- a/core/events/src/main/kotlin/com/correx/core/approvals/GrantScope.kt +++ b/core/events/src/main/kotlin/com/correx/core/approvals/GrantScope.kt @@ -8,8 +8,15 @@ import kotlinx.serialization.Serializable sealed interface GrantScope { // toolName binds this grant to a specific operation kind (e.g. "shell", "write_file"). // A null toolName is rejected at grant-creation time; it is kept nullable here only - // for backward-compatible deserialization of legacy events. + // for backward-compatible deserialization of legacy events. Every operator-creatable + // scope is tool-bound so a grant can never become a blanket "approve everything" + // (that is YOLO mode, configured separately). @Serializable data class SESSION(val toolName: String? = null) : GrantScope @Serializable data class STAGE(val stageId: StageId) : GrantScope - @Serializable data class PROJECT(val projectId: ProjectId) : GrantScope + + /** Auto-approve [toolName] for any session whose workspace resolves to [projectId]. */ + @Serializable data class PROJECT(val projectId: ProjectId, val toolName: String? = null) : GrantScope + + /** Auto-approve [toolName] for every session on this machine (the widest scope). */ + @Serializable data class GLOBAL(val toolName: String? = null) : GrantScope } diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt index 7be575a9..87731aab 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt @@ -2,6 +2,8 @@ package com.correx.core.kernel.orchestration import com.correx.core.approvals.ApprovalOutcome import com.correx.core.approvals.ApprovalStatus +import com.correx.core.approvals.GRANT_LEDGER_SESSION_ID +import com.correx.core.approvals.ProjectIdentity import com.correx.core.approvals.Tier import com.correx.core.approvals.DefaultApprovalRepository import com.correx.core.approvals.domain.ApprovalEngine @@ -727,10 +729,15 @@ abstract class SessionOrchestrator( if (tier.isAtMost(Tier.T1) && !plane2Prompts) { // no approval needed } else { - val approvalState = approvalRepository.getApprovalState(sessionId) - val activeGrants = approvalState.grants.values.toList() + // Grants in effect = this session's own (SESSION/STAGE) unioned with the + // cross-session ledger (PROJECT/GLOBAL). projectId is derived from the bound + // workspace root so PROJECT grants match later sessions on the same repo. + val sessionGrants = approvalRepository.getApprovalState(sessionId).grants.values + val ledgerGrants = approvalRepository.getApprovalState(GRANT_LEDGER_SESSION_ID).grants.values + val activeGrants = (sessionGrants + ledgerGrants).toList() + val projectId = effectives.policy?.workspaceRoot?.let { ProjectIdentity.of(it.toString()) } val approvalCtx = ApprovalContext( - identity = ApprovalScopeIdentity(sessionId, stageId, projectId = null), + identity = ApprovalScopeIdentity(sessionId, stageId, projectId = projectId), mode = ApprovalMode.PROMPT, ) val requestId = ApprovalRequestId(UUID.randomUUID().toString()) From 8df0ec750cd472f24c090835195e0034a70f93df Mon Sep 17 00:00:00 2001 From: kami Date: Sun, 21 Jun 2026 22:35:01 +0000 Subject: [PATCH 046/107] feat(tui-go): grant scope picker (A) + standing-grants viewer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface the new cross-session grant scopes in the TUI. - Approve-always (A) now opens a scope picker instead of immediately creating a SESSION grant: choose this session / this project / everywhere. SESSION stays the default (A then enter/s = old behaviour); p and g create PROJECT / GLOBAL grants. The grant + approval are sent on confirm (esc cancels, leaving the call pending). - New "grants" palette command + G shortcut open a standing-grants viewer (OverlayGrants) listing the active PROJECT/GLOBAL grants — scope, tool, permitted tiers, project path — with x/enter to revoke. The server's RevokeGrant reply (a fresh grant.list) refreshes it in place. - protocol.go: TypeGrantList + GrantDto; RevokeGrant/ListGrants encoders; CreateGrant now documents the wider scopes. server.go decodes grant.list into m.grants and treats it as a non-session global reply. - render-matrix coverage: grant.list classified as a non-rendering query reply (populates the overlay, not the transcript). - demo.go: grants + grant-scope preview kinds. go build / vet / test green; rendered both overlays via cmd/preview. Co-Authored-By: Claude Opus 4.8 --- apps/tui-go/internal/app/demo.go | 24 ++++ apps/tui-go/internal/app/model.go | 11 ++ apps/tui-go/internal/app/overlays.go | 73 +++++++++++ .../tui-go/internal/app/render_matrix_test.go | 1 + apps/tui-go/internal/app/server.go | 8 +- apps/tui-go/internal/app/update.go | 117 ++++++++++++++++-- apps/tui-go/internal/protocol/protocol.go | 28 ++++- 7 files changed, 252 insertions(+), 10 deletions(-) diff --git a/apps/tui-go/internal/app/demo.go b/apps/tui-go/internal/app/demo.go index 7bdaa7c3..5e203e74 100644 --- a/apps/tui-go/internal/app/demo.go +++ b/apps/tui-go/internal/app/demo.go @@ -152,6 +152,30 @@ func PreviewFrame(kind string, w, h int) string { m.overlay = OverlayStats m.statsFor = "04a546aa" m.stats = sampleStats() + + case "grant-scope": + m.connected = true + m.currentModel = "llama-cpp:default" + m.sessions = sampleSessions() + m.selectedID = "04a546aa" + m.sessionEntered = true + m.grantFor = &Approval{RequestID: "req-1", SessionID: "04a546aa", Tier: "T3", ToolName: "file_write"} + m.grantScopeIndex = 1 + m.overlay = OverlayGrantScope + + case "grants": + m.connected = true + m.currentModel = "llama-cpp:default" + m.sessions = sampleSessions() + m.selectedID = "04a546aa" + m.sessionEntered = true + m.grants = []protocol.GrantDto{ + {GrantID: "g-1", Scope: "GLOBAL", ToolName: "read_file", Tiers: []string{"T1", "T2"}}, + {GrantID: "g-2", Scope: "PROJECT", ToolName: "file_write", Tiers: []string{"T3"}, ProjectID: "/home/kami/Programs/correx"}, + {GrantID: "g-3", Scope: "GLOBAL", ToolName: "shell", Tiers: []string{"T3", "T4"}}, + } + m.grantIndex = 1 + m.overlay = OverlayGrants } return m.View() } diff --git a/apps/tui-go/internal/app/model.go b/apps/tui-go/internal/app/model.go index f3a733a4..f4a71ae1 100644 --- a/apps/tui-go/internal/app/model.go +++ b/apps/tui-go/internal/app/model.go @@ -59,6 +59,8 @@ const ( OverlaySessions OverlayFiles OverlayStatusbar + OverlayGrants + OverlayGrantScope ) // RouterEntry is one line in a session's conversation transcript. @@ -320,6 +322,15 @@ type Model struct { sbHidden map[string]bool sbIndex int + // standing grants (OverlayGrants) — active PROJECT/GLOBAL grants from the grant.list reply. + grants []protocol.GrantDto + grantsLoading bool + grantIndex int + // grant scope picker (OverlayGrantScope) — chosen when the operator presses A on an approval. + // grantScopeIndex selects session/project/global; grantFor holds the approval being widened. + grantScopeIndex int + grantFor *Approval + // animation frame int // tick counter; drives spinner + caret blink ticking bool // true while a tick loop is scheduled. The loop is gated on animating() diff --git a/apps/tui-go/internal/app/overlays.go b/apps/tui-go/internal/app/overlays.go index 832938b6..38fc79ad 100644 --- a/apps/tui-go/internal/app/overlays.go +++ b/apps/tui-go/internal/app/overlays.go @@ -128,6 +128,10 @@ func (m Model) renderOverlay(base string) string { return m.center(m.filesModal()) case OverlayStatusbar: return m.center(m.statusbarModal()) + case OverlayGrants: + return m.center(m.grantsModal()) + case OverlayGrantScope: + return m.center(m.grantScopeModal()) } return base } @@ -615,6 +619,75 @@ func (m Model) statusbarModal() string { return m.center(modal) } +// grantsModal lists the active standing (PROJECT/GLOBAL) grants and lets the operator revoke one. +// These are cross-session auto-approvals, so the board opens from anywhere. +func (m Model) grantsModal() string { + t := m.theme + w := m.modalWidth() + + var b strings.Builder + b.WriteString(m.titleLine("standing grants") + "\n\n") + b.WriteString(mbg(t, " cross-session auto-approvals in force (tool-bound)", t.P.Faint) + "\n\n") + + switch { + case m.grantsLoading: + b.WriteString(mbg(t, " loading…", t.P.Faint) + "\n") + case len(m.grants) == 0: + b.WriteString(mbg(t, " none — press A on an approval to grant project / global", t.P.Faint) + "\n") + default: + for i, g := range m.grants { + marker := mbg(t, " ", t.P.BgPanel) + fg := t.P.Fg + if i == m.grantIndex { + marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▌ ") + fg = t.P.FgStrong + } + scope := lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Render(padRaw(g.Scope, 8)) + tool := lipgloss.NewStyle().Foreground(fg).Background(t.P.BgPanel).Render(padRaw(g.ToolName, 16)) + tiers := mbg(t, "≤"+strings.Join(g.Tiers, ","), t.P.Faint) + b.WriteString(marker + scope + tool + tiers) + if g.Scope == "PROJECT" && g.ProjectID != "" { + b.WriteString(mbg(t, " "+clip(g.ProjectID, w-52), t.P.Faint)) + } + b.WriteString("\n") + } + } + b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "select"}, {"x", "revoke"}, {"G/esc", "close"}})) + modal := t.Overlay.Width(w).Render(b.String()) + return m.center(modal) +} + +// grantScopeModal is the A (approve-always) breadth picker: choose how widely to stop being asked +// for the pending tool — this session, this project, or everywhere. +func (m Model) grantScopeModal() string { + t := m.theme + w := m.modalWidth() + + tool := "" + if m.grantFor != nil { + tool = m.grantFor.ToolName + } + var b strings.Builder + b.WriteString(m.titleLine("approve always — choose scope") + "\n\n") + b.WriteString(mbg(t, " stop asking for ", t.P.Faint) + + lipgloss.NewStyle().Foreground(t.P.FgStrong).Background(t.P.BgPanel).Render(tool) + "\n\n") + + for i, opt := range grantScopeOptions() { + marker := mbg(t, " ", t.P.BgPanel) + labelFg := t.P.Fg + if i == m.grantScopeIndex { + marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▌ ") + labelFg = t.P.FgStrong + } + key := lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Render(padRaw(opt.key, 3)) + title := lipgloss.NewStyle().Foreground(labelFg).Background(t.P.BgPanel).Render(padRaw(opt.title, 16)) + b.WriteString(marker + key + title + mbg(t, opt.hint, t.P.Faint) + "\n") + } + b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "move"}, {"enter", "grant"}, {"esc", "cancel"}})) + modal := t.Overlay.Width(w).Render(b.String()) + return m.center(modal) +} + func (m Model) toolPaletteModal() string { t := m.theme w := m.modalWidth() diff --git a/apps/tui-go/internal/app/render_matrix_test.go b/apps/tui-go/internal/app/render_matrix_test.go index 55af662a..5b17ed7d 100644 --- a/apps/tui-go/internal/app/render_matrix_test.go +++ b/apps/tui-go/internal/app/render_matrix_test.go @@ -598,6 +598,7 @@ var nonRenderingEventTypes = map[string]string{ protocol.TypeRouterResponse: "recognized no-op: superseded by chat.turn on the global stream", protocol.TypeProtocolError: "transport diagnostic: explicit no-op, not surfaced", protocol.TypeFileList: "query reply: populates the @ file picker overlay, not the transcript", + protocol.TypeGrantList: "query reply: populates the standing-grants overlay, not the transcript", } // TestRenderMatrixCoversEveryEventType is the load-bearing matrix guarantee: it scans diff --git a/apps/tui-go/internal/app/server.go b/apps/tui-go/internal/app/server.go index 2ff8cb4a..b3d7fc39 100644 --- a/apps/tui-go/internal/app/server.go +++ b/apps/tui-go/internal/app/server.go @@ -333,6 +333,12 @@ func (m *Model) applyServer(msg protocol.ServerMessage) { if m.fileIndex >= len(m.filteredFiles()) { m.fileIndex = 0 } + case protocol.TypeGrantList: + m.grants = msg.Grants + m.grantsLoading = false + if m.grantIndex >= len(m.grants) { + m.grantIndex = 0 + } case protocol.TypeConfigSnapshot: m.configFields = msg.ConfigFields m.configRestart = msg.ConfigRestartRequired @@ -378,7 +384,7 @@ func sessionIDOf(msg protocol.ServerMessage) string { protocol.TypeWorkflowList, protocol.TypeRouterResponse, protocol.TypeModelChanged, protocol.TypeModelList, protocol.TypeResourceStatus, protocol.TypeArtifactList, protocol.TypeConfigSnapshot, protocol.TypeSessionStats, - protocol.TypeIdeaList, protocol.TypeFileList: + protocol.TypeIdeaList, protocol.TypeFileList, protocol.TypeGrantList: return "" default: return msg.SessionID diff --git a/apps/tui-go/internal/app/update.go b/apps/tui-go/internal/app/update.go index c54de861..1285d8bb 100644 --- a/apps/tui-go/internal/app/update.go +++ b/apps/tui-go/internal/app/update.go @@ -275,6 +275,8 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { return m, m.openSessions() case "S": m.openStats() + case "G": + m.openGrants() case "g": m.openConfig() case "m": @@ -573,19 +575,17 @@ func (m Model) normalEnter() (tea.Model, tea.Cmd) { return m, nil } +// autoApprove (A) opens the scope picker so the operator chooses how broadly to stop being +// asked: this session (the old behaviour), this project, or globally. The actual grant + +// approval are sent by applyGrantScope once a scope is picked. func (m Model) autoApprove() (tea.Model, tea.Cmd) { s := m.session(m.selectedID) if s == nil || s.Pending == nil { return m, nil } - p := s.Pending - m.client.Send(protocol.CreateGrant(p.SessionID, "SESSION", []string{p.Tier}, "auto-approved via TUI", p.ToolName)) - m.client.Send(protocol.ApprovalResponse(p.RequestID, "APPROVE", nil)) - s.removeApproval(p.RequestID) - m.steerBuffer = "" - m.steering = false - m.approvalArmed = false - m.approvalDismissed = false + m.grantFor = s.Pending + m.grantScopeIndex = 0 + m.overlay = OverlayGrantScope return m, nil } @@ -748,10 +748,108 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { case runeIs(k, "g"): m.overlay = OverlayNone } + case OverlayGrants: + switch { + case k.Type == tea.KeyUp || runeIs(k, "k"): + if m.grantIndex > 0 { + m.grantIndex-- + } + case k.Type == tea.KeyDown || runeIs(k, "j"): + if m.grantIndex < len(m.grants)-1 { + m.grantIndex++ + } + case k.Type == tea.KeyEnter || runeIs(k, "x"): + if m.grantIndex >= 0 && m.grantIndex < len(m.grants) { + m.client.Send(protocol.RevokeGrant(m.grants[m.grantIndex].GrantID)) + // The server replies with a fresh grant.list, which refreshes m.grants. + } + case runeIs(k, "G"): + m.overlay = OverlayNone + } + case OverlayGrantScope: + return m.handleGrantScopeKey(k) } return m, nil } +// grantScopeOption is one breadth choice in the A (approve-always) scope picker. wire is the +// CreateGrant scope name: SESSION is per-session (dies with it), PROJECT covers any session on +// the same repo, GLOBAL every session on this machine. +type grantScopeOption struct{ key, wire, title, hint string } + +func grantScopeOptions() []grantScopeOption { + return []grantScopeOption{ + {"s", "SESSION", "this session", "until this session ends"}, + {"p", "PROJECT", "this project", "any session on this repo"}, + {"g", "GLOBAL", "everywhere", "any session on this machine"}, + } +} + +// handleGrantScopeKey drives the scope picker: ↑/↓ move, s/p/g jump-and-confirm, enter +// confirms the highlighted scope. esc (handled upstream) cancels without granting. +func (m Model) handleGrantScopeKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { + opts := grantScopeOptions() + switch { + case k.Type == tea.KeyUp || runeIs(k, "k"): + if m.grantScopeIndex > 0 { + m.grantScopeIndex-- + } + case k.Type == tea.KeyDown || runeIs(k, "j"): + if m.grantScopeIndex < len(opts)-1 { + m.grantScopeIndex++ + } + case runeIs(k, "s"): + m.grantScopeIndex = 0 + return m.applyGrantScope() + case runeIs(k, "p"): + m.grantScopeIndex = 1 + return m.applyGrantScope() + case runeIs(k, "g"): + m.grantScopeIndex = 2 + return m.applyGrantScope() + case k.Type == tea.KeyEnter: + return m.applyGrantScope() + } + return m, nil +} + +// applyGrantScope creates the chosen-scope grant for the pending tool, approves the current +// call, and closes the picker. The SESSION choice is identical to the old A behaviour. +func (m Model) applyGrantScope() (tea.Model, tea.Cmd) { + p := m.grantFor + if p == nil { + m.overlay = OverlayNone + return m, nil + } + opts := grantScopeOptions() + idx := m.grantScopeIndex + if idx < 0 || idx >= len(opts) { + idx = 0 + } + scope := opts[idx].wire + m.client.Send(protocol.CreateGrant(p.SessionID, scope, []string{p.Tier}, "granted via TUI ("+scope+")", p.ToolName)) + m.client.Send(protocol.ApprovalResponse(p.RequestID, "APPROVE", nil)) + if s := m.session(p.SessionID); s != nil { + s.removeApproval(p.RequestID) + } + m.grantFor = nil + m.overlay = OverlayNone + m.steerBuffer = "" + m.steering = false + m.approvalArmed = false + m.approvalDismissed = false + return m, nil +} + +// openGrants opens the standing-grants viewer and requests the active PROJECT/GLOBAL grants. +// The board is global (not session-scoped), so it opens from anywhere. +func (m *Model) openGrants() { + m.overlay = OverlayGrants + m.grantIndex = 0 + m.grantsLoading = true + m.client.Send(protocol.ListGrants()) +} + // toggleStatusSegment flips a status-bar segment's visibility and persists the change. func (m *Model) toggleStatusSegment(id string) { if m.sbHidden == nil { @@ -869,6 +967,7 @@ func paletteCommands() []paletteCmd { {"stats", "S", "session stats", "metrics for the selected session"}, {"sessions", "R", "resume session", "browse / resume recent sessions"}, {"config", "g", "edit config", "view / change correx settings"}, + {"grants", "G", "grants", "view / revoke standing grants"}, {"statusbar", "", "status bar", "show / hide status-bar segments"}, {"mode", "s", "toggle mode", "switch chat / steering"}, {"cancel", "c", "cancel session", "stop the selected session"}, @@ -912,6 +1011,8 @@ func (m Model) execPalette(id string) (tea.Model, tea.Cmd) { return m, m.openSessions() case "config": m.openConfig() + case "grants": + m.openGrants() case "statusbar": m.overlay = OverlayStatusbar m.sbIndex = 0 diff --git a/apps/tui-go/internal/protocol/protocol.go b/apps/tui-go/internal/protocol/protocol.go index dc6b363e..276b7cca 100644 --- a/apps/tui-go/internal/protocol/protocol.go +++ b/apps/tui-go/internal/protocol/protocol.go @@ -56,6 +56,7 @@ const ( TypeSessionStats = "session.stats" TypeIdeaList = "idea.list" TypeFileList = "file.list" + TypeGrantList = "grant.list" ) // ServerMessage is a flat decode of every server->client variant. Field names @@ -154,6 +155,20 @@ type ServerMessage struct { // file.list — the session workspace's file paths (for the @ file-ref picker) Paths []string `json:"paths"` + + // grant.list — the active cross-session (PROJECT/GLOBAL) standing grants + Grants []GrantDto `json:"grants"` +} + +// GrantDto is one standing cross-session grant. Mirrors the Kotlin GrantDto. +type GrantDto struct { + GrantID string `json:"grantId"` + Scope string `json:"scope"` // "PROJECT" | "GLOBAL" + ToolName string `json:"toolName"` + ProjectID string `json:"projectId"` + Tiers []string `json:"tiers"` + Reason string `json:"reason"` + ExpiresAtMs *int64 `json:"expiresAtMs"` } // IdeaDto is one idea on the cross-session board. Mirrors the Kotlin IdeaDto. @@ -457,7 +472,8 @@ func ClearModelPin() []byte { } // CreateGrant pre-authorizes a tool/tier so future calls skip the approval gate. -// scope is "SESSION" or "STAGE"; permittedTiers are tier names like "T3". +// scope is "SESSION", "PROJECT", or "GLOBAL"; permittedTiers are tier names like "T3". +// For PROJECT/GLOBAL the server derives the projectId from the session's workspace. func CreateGrant(sessionID, scope string, permittedTiers []string, reason, toolName string) []byte { return encode("CreateGrant", map[string]any{ "sessionId": sessionID, @@ -470,6 +486,16 @@ func CreateGrant(sessionID, scope string, permittedTiers []string, reason, toolN }) } +// RevokeGrant revokes a standing (PROJECT/GLOBAL) grant by id; replies with a fresh grant.list. +func RevokeGrant(grantID string) []byte { + return encode("RevokeGrant", map[string]any{"grantId": grantID}) +} + +// ListGrants requests the active cross-session grants (replied to with a grant.list). +func ListGrants() []byte { + return encode("ListGrants", map[string]any{}) +} + // Hello is the first frame sent on every (re)connect. It carries the client's // working directory so the server can bind a workspace before any session starts. func Hello(workingDir string) []byte { From 67855d85937a63fe082b49358c4be5b2ced93d1c Mon Sep 17 00:00:00 2001 From: kami Date: Sun, 21 Jun 2026 22:36:06 +0000 Subject: [PATCH 047/107] docs(retro): record cross-session grants (PROJECT/GLOBAL) + revoke c36d41b + 8df0ec7, with the cross-session live-QA gate noted. Co-Authored-By: Claude Opus 4.8 --- RETRO.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/RETRO.md b/RETRO.md index 24e2ee33..536da916 100644 --- a/RETRO.md +++ b/RETRO.md @@ -7,6 +7,27 @@ could not be run here. --- +## 2026-06-21 — wider grants + revoke (branch `feat/backlog-burndown`) + +Operator-requested (not a prior BACKLOG item): grants that outlive a single session, plus a +revoke path. Two commits, each compiled + unit-tested green. + +| Commit | What | +|--------|------| +| `c36d41b` | **Cross-session grant scopes (PROJECT/GLOBAL) + revoke.** `GrantScope.GLOBAL(toolName)` added; `PROJECT` made tool-bound. Engine `scopeMatches`: GLOBAL matches the bound tool anywhere, PROJECT when the session's `projectId` (derived from the bound workspace root via `ProjectIdentity.of`) AND tool match. New reserved `GRANT_LEDGER_SESSION_ID` stream holds PROJECT/GLOBAL grants; the approval gate unions the ledger's grants with the session's. `RevokeGrant` → `ApprovalGrantExpiredEvent` on the ledger (reducer already drops it); `ListGrants`/`GrantList` expose the active standing grants. **Server-side T2 grant ceiling removed** per operator request — a grant may now authorize any tier (incl. T3/T4); tool-binding is the remaining guard. New `GrantScopeMatchingTest` (5). | +| `8df0ec7` | **TUI: scope picker (A) + standing-grants viewer.** `A` (approve-always) opens a session/project/global picker (SESSION default = old behaviour); `p`/`g` create wider grants. `grants` palette command + `G` shortcut open `OverlayGrants` listing scope/tool/tiers/path with `x`/enter to revoke. `protocol.go`: `TypeGrantList`+`GrantDto`, `RevokeGrant`/`ListGrants` encoders. Both overlays rendered via `cmd/preview` (kinds `grants`, `grant-scope`). | + +**Why the design:** session grants are event-sourced per-session and die with the session; +project/global grants must outlive any one session, so they live in a dedicated ledger stream +the gate folds in — keeps grants in the audit log (invariant #9) rather than config. + +**⚠️ Live-QA gate (move out of RETRO once passed):** unit tests cover the engine matching, +reducer, and ledger fold, but the *cross-session* behaviour — create a GLOBAL/PROJECT grant in +session A, confirm a **different** session B auto-clears that tool, then revoke and confirm it +prompts again — needs a running server + model. Not runnable in this sandbox. + +--- + ## 2026-06-20 — backlog burndown (branch `feat/backlog-burndown`) Nine tracks, each compiled + unit-tested green before commit. From 3f4c45a7b08c3ddd51b64d1c28a6a9179d09f5e5 Mon Sep 17 00:00:00 2001 From: kami Date: Sun, 21 Jun 2026 22:41:02 +0000 Subject: [PATCH 048/107] docs(qa): live-QA plan for cross-session grants + revoke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QA-grants.md (10 checks): the headline is grant-in-session-A → auto-clear-in-session-B, keyed on ApprovalDecisionResolved(AUTO_APPROVED, reason="grant:") with no APPROVAL_PENDING; plus tool-binding, no-cap T3/T4, PROJECT same-repo vs different-repo isolation, restart persistence, revoke-re-prompts, session-grant-no-leak regression, and replay determinism. Indexed in README; BACKLOG drafted-plans pointer updated. Co-Authored-By: Claude Opus 4.8 --- BACKLOG.md | 5 ++-- docs/qa/QA-grants.md | 55 ++++++++++++++++++++++++++++++++++++++++++++ docs/qa/README.md | 10 ++++---- 3 files changed, 64 insertions(+), 6 deletions(-) create mode 100644 docs/qa/QA-grants.md diff --git a/BACKLOG.md b/BACKLOG.md index 439879e8..61059635 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -46,8 +46,9 @@ matching bullets below are superseded; only the noted follow-ups remain live: **📋 Live-QA plans drafted** (`docs/qa/QA-*.md`, index `docs/qa/README.md`, env `docs/qa/ENV.md` + `scripts/qa/`): research-egress (D), architect-contradiction (B§4), llama-health-probe (A§4), idea-promotion (E), -reviewer-static-first (B§5, partial), and the **brief-echo-gate ARM-IT** plan (C-A1 — run it before -arming the gate; it's the go/no-go). These features stay listed until their plan **passes live**, then move to RETRO. +reviewer-static-first (B§5, partial), the **brief-echo-gate ARM-IT** plan (C-A1 — run it before +arming the gate; it's the go/no-go), and **grants** (cross-session PROJECT/GLOBAL + revoke — `c36d41b` `8df0ec7`, +see RETRO 2026-06-21). These features stay listed until their plan **passes live**, then move to RETRO. --- diff --git a/docs/qa/QA-grants.md b/docs/qa/QA-grants.md new file mode 100644 index 00000000..807fc676 --- /dev/null +++ b/docs/qa/QA-grants.md @@ -0,0 +1,55 @@ +# QA Plan: cross-session grants (PROJECT/GLOBAL) + revoke — `c36d41b` `8df0ec7` + +**Status:** DRAFT +**Run date / operator:** +**BACKLOG item:** operator-requested wider grants — see `RETRO.md` 2026-06-21 "wider grants + revoke" (the cross-session live-QA gate noted there). On PASS the gate clears and the entry stays in RETRO with the run date; on FAIL the misses refile into `BACKLOG.md`. + +--- + +## Preconditions + +- [ ] **server build/branch:** `feat/backlog-burndown` — _rebuild only with the server stopped (lazy classloading breaks otherwise)_ +- [ ] **llama-server + model:** a tool-calling-capable model that drives a **T2+** tool call reproducibly across runs. The `healthcheck` workflow's `write_script` → `file_write` (T3) is the reference fixture (used by the other QA demos); any workflow with a deterministic write/shell call works. +- [ ] **external deps:** none (no SearXNG / network needed — this exercises the approval gate, not research). +- [ ] **config synced:** `~/.config/correx` matches `examples/*` + `docs/schemas/*` (`scripts/qa/sync-config.sh`). +- [ ] **fixtures/seed:** two distinct workspace dirs — **W1** (e.g. `/home/kami/qa/repoA`) and **W2** (`/home/kami/qa/repoB`) — each launchable as a session workspace, for the PROJECT-isolation check. The TUI binds its cwd as the workspace (the `Hello` frame), so run the TUI from W1 vs W2 to switch projects. + +## Acceptance gate (one sentence) + +> A GLOBAL/PROJECT grant created in one session auto-clears its **bound tool** in a *different* session — recorded as `ApprovalDecisionResolved(AUTO_APPROVED, reason="grant:")` with **no** `APPROVAL_PENDING` pause — scoped correctly (PROJECT only on the same workspace; tool-bound; T3/T4 honoured), and **revoking** it makes that tool prompt again — all visible in the grant ledger. + +## Checks + +The decisive signal throughout: a grant match emits `ApprovalDecisionResolvedEvent` with +`outcome = AUTO_APPROVED` and `reason = "grant:"` and **falls through to execute** +(no `OrchestrationPausedEvent("APPROVAL_PENDING")`, no `ApprovalRequestedEvent` for that +invocation). The human path emits both of those. "It didn't prompt" must be backed by the +*absence* of `APPROVAL_PENDING` in the log, not just a TUI impression. + +| # | Action | Expected observable evidence | Result | +|---|--------|------------------------------|--------| +| 1 | **Baseline.** Run a session (W1) to a T2+ tool call with no grant in force. | The call pauses for approval: `OrchestrationPausedEvent(reason="APPROVAL_PENDING")` + `ApprovalRequestedEvent(toolName="file_write", tier=T3)` in `correx events {id}`; the TUI shows the approval band. | | +| 2 | **Create GLOBAL grant.** At that prompt press `A`, then `g`. | Exactly one `ApprovalGrantCreatedEvent` in the **grant ledger** (`GET /sessions/__grant_ledger__/events`) with `scope` = `GLOBAL`, `toolName="file_write"`, `permittedTiers` including T3. The current call then resolves `ApprovalDecisionResolved(AUTO_APPROVED, reason="grant:")` and executes. The TUI grants viewer (`G`) lists the grant (GLOBAL · file_write · ≤T3). | | +| 3 | **Cross-session auto-clear (headline).** Start a **second, separate** session that makes the same `file_write` call. | That session records `ApprovalDecisionResolved(AUTO_APPROVED, reason="grant:")` and **no** `OrchestrationPausedEvent("APPROVAL_PENDING")` / **no** `ApprovalRequestedEvent` for the invocation; the tool executes. Proves the ledger is unioned across sessions. | | +| 4 | **Tool-binding.** In a session have the agent call a *different* T2+ tool (e.g. `shell`). | `shell` still raises `OrchestrationPausedEvent("APPROVAL_PENDING")` + `ApprovalRequestedEvent(toolName="shell")` — the GLOBAL grant is bound to `file_write` only, never a blanket open. | | +| 5 | **No tier cap (T3/T4).** Confirm the auto-clear in #3 was a **T3** call. (Optional: at a prompt, grant a tool whose permitted tiers reach T4 and drive a T4 call.) | The ≥T3 call resolves `AUTO_APPROVED` — the removed T2 ceiling no longer blocks high tiers. (The old build rejected `CreateGrant` tiers > T2.) | | +| 6a | **PROJECT scope — same repo.** Revoke the GLOBAL grant (#8). From the TUI in **W1**, drive `file_write`, press `A` then `p`. Then start another session whose cwd is **W1**. | Ledger `ApprovalGrantCreatedEvent` `scope = PROJECT`, `projectId` = canonical absolute path of W1 (e.g. `/home/kami/qa/repoA`). The new W1 session auto-clears `file_write` (`AUTO_APPROVED, reason="grant:"`). | | +| 6b | **PROJECT scope — different repo.** Start a session whose cwd is **W2**, same `file_write` call. | The W2 session **prompts** (`APPROVAL_PENDING` + `ApprovalRequestedEvent`) — the PROJECT grant does not match a different workspace's derived `projectId`. | | +| 7 | **Persistence across restart.** Stop and restart the server; reopen the TUI grants viewer (`G`) / re-read `GET /sessions/__grant_ledger__/events`. | The standing grant is still listed (the ledger stream replays); a fresh session still auto-clears its tool. | | +| 8 | **Revoke.** In the grants viewer press `x` on the grant (or send `RevokeGrant`). | An `ApprovalGrantExpiredEvent(grantId=)` is appended to the **ledger**; the viewer's next `grant.list` no longer lists it. A new session calling that tool now **prompts again** (`APPROVAL_PENDING` + `ApprovalRequestedEvent` reappear). | | +| 9 | **Session-grant does not leak (regression).** At a prompt press `A` then `s` (or Enter); then start a *different* session with the same call. | The SESSION grant is written to the **session's own** stream (not `__grant_ledger__`); the other session still **prompts** (`APPROVAL_PENDING`). Confirms SESSION scope stayed per-session. | | +| 10 | **Audit + replay.** `GET /sessions/__grant_ledger__/events`; `correx replay {consuming-session-id}`. | The ledger shows the create + expire pair (nothing deleted — invariant #9). Replay reproduces the same AUTO_APPROVED / PENDING decisions deterministically from the recorded grants (no re-prompt). | | + +Evidence sources: the TUI grants viewer (`G`, backed by `ListGrants`→`grant.list`), `GET /sessions/__grant_ledger__/events` (the ledger is a real event stream — `correx events __grant_ledger__` if the CLI accepts the raw stream id), `correx events {id}` / `GET /sessions/{id}/events` for the consuming sessions, server logs (MDC sessionId), and `correx replay {id}` for determinism. + +## Out of scope (explicitly NOT covered this pass) + +- The §D standalone web-approval client (third client) — separate track, not built. +- **STAGE**-scope grants — internal, unchanged by this work. +- Time-based expiry (`expiresAt`): the TUI does not set one today, so a grant is standing-until-revoked; the engine *does* honour `expiresAt` if a future client sets it, but that path is not exercised here. +- The LLM-annotation command-card layer and other unrelated TUI overlays. + +## Disposition + +- **PASS** → the cross-session gate in `RETRO.md` (2026-06-21) clears; set this plan's Status: PASSED with the run date and the cited `ApprovalGrantCreatedEvent` / `AUTO_APPROVED reason="grant:"` / `ApprovalGrantExpiredEvent` evidence. +- **FAIL** → file each failure as a numbered finding in `BACKLOG.md` (action + exact repro + the wrong/missing signal — e.g. "step 3: session B still emitted APPROVAL_PENDING → ledger union not consulted"), fix, then re-run only the failed checks. Status: FAILED until green. diff --git a/docs/qa/README.md b/docs/qa/README.md index 00829ff8..31c44bdf 100644 --- a/docs/qa/README.md +++ b/docs/qa/README.md @@ -17,14 +17,16 @@ to RETRO with the run date + cited evidence; on FAIL → refile each miss as a n | `QA-idea-promotion.md` | idea-board → `.correx/project.toml` promotion | `f107ff5` | bound workspace (router model optional — can seed the event) | | `QA-reviewer-static-first.md` | reviewer static-findings exclusion (PARTIAL — producing stage not built) | `447fc7a` | seed `StaticFindingsRecordedEvent` | | `QA-brief-echo-gate.md` | **ARM-IT** plan: brief echo-back gate (off by default; risky to arm blind) | `1df7af5` | prod-candidate model | +| `QA-grants.md` | cross-session grants (PROJECT/GLOBAL) + revoke | `c36d41b` `8df0ec7` | model + a T2+ tool call, 2 sessions, 2 workspaces | ## Order of attack (suggested) 1. **`QA-llama-health-probe`** + **`QA-idea-promotion`** — cheapest, least model-dependent (health is liveness; promotion can be seeded). Quick wins. -2. **`QA-research-egress`** — once SearXNG is up. -3. **`QA-architect-contradiction`** — needs a real embedder + two sessions. -4. **`QA-brief-echo-gate`** — run this **before** arming the gate anywhere real; check 2 (100% parseable-echo across runs) is the go/no-go for production. -5. **`QA-reviewer-static-first`** — partial until the static-check stage seam lands. +2. **`QA-grants`** — no network/embedder; just a model that drives one repeatable T2+ tool call. The headline check (grant in session A → auto-clear in session B) is a strong, cheap signal. +3. **`QA-research-egress`** — once SearXNG is up. +4. **`QA-architect-contradiction`** — needs a real embedder + two sessions. +5. **`QA-brief-echo-gate`** — run this **before** arming the gate anywhere real; check 2 (100% parseable-echo across runs) is the go/no-go for production. +6. **`QA-reviewer-static-first`** — partial until the static-check stage seam lands. ## Still-open §F live-QA gates (no dedicated plan yet — same env applies) From 6a06f2ead569445be4225a2b7aa40a914ff0627c Mon Sep 17 00:00:00 2001 From: kami Date: Sun, 21 Jun 2026 23:00:18 +0000 Subject: [PATCH 049/107] feat(tui-go): inline action rows in the OUTPUT transcript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface side-effecting ("external feedback") events in the conversation flow, opencode-style, instead of only in the EVENTS side log. - New RouterEntry role "action" (glyph + text) appended at arrival so it interleaves with chat turns by order. Rendered dim with an accent gutter glyph; the EVENTS panel still carries the full stream. - Surfaced: tool start (⏵), tool done (✓ / ✎ wrote (+a −b) for a diff), tool failed (✗), rejected (✕ blocked), approval resolved (⌘ approved / ✕ rejected, noting "· via grant" on a grant auto-approve), and grant create/revoke (⊞ / ⊟ · scope). ApprovalResolved names no tool on the wire, so the tool is recovered from the pending queue before the gate is dropped. - Toggle: "inline actions" palette command flips actionsHidden (rows are always recorded, gated at render, so toggling reveals history); persisted in tui-prefs.json. prefs.go refactored to load/mutate/save so the new field and statusbarHidden no longer clobber each other. - demo.go: "actions" preview kind. go build / vet / test green; rendered the flow via cmd/preview. Co-Authored-By: Claude Opus 4.8 --- apps/tui-go/internal/app/demo.go | 23 ++++++++ apps/tui-go/internal/app/model.go | 8 ++- apps/tui-go/internal/app/prefs.go | 69 +++++++++++++++--------- apps/tui-go/internal/app/server.go | 86 ++++++++++++++++++++++++++++++ apps/tui-go/internal/app/update.go | 24 ++++++++- apps/tui-go/internal/app/view.go | 8 +++ 6 files changed, 191 insertions(+), 27 deletions(-) diff --git a/apps/tui-go/internal/app/demo.go b/apps/tui-go/internal/app/demo.go index 5e203e74..c10be9dc 100644 --- a/apps/tui-go/internal/app/demo.go +++ b/apps/tui-go/internal/app/demo.go @@ -163,6 +163,29 @@ func PreviewFrame(kind string, w, h int) string { m.grantScopeIndex = 1 m.overlay = OverlayGrantScope + case "actions": + m.connected = true + m.currentModel = "llama-cpp:default" + m.sessions = sampleSessions() + m.selectedID = "04a546aa" + m.sessionEntered = true + m.routerConnected = true + m.routerMessages["04a546aa"] = []RouterEntry{ + {Role: "user", Content: "run the healthcheck and write the script"}, + {Role: "router", Content: "I'll create the script, then run it."}, + {Role: "action", Icon: "⏵", Content: "file_write"}, + {Role: "action", Icon: "✎", Content: "wrote healthcheck.sh (+4 −0)"}, + {Role: "action", Icon: "⌘", Content: "approved file_write"}, + {Role: "action", Icon: "⏵", Content: "shell"}, + {Role: "action", Icon: "✓", Content: "shell · exit 0"}, + {Role: "action", Icon: "⊞", Content: "granted file_write · global"}, + {Role: "router", Content: "Done — script written and the healthcheck passes."}, + } + if s := m.session("04a546aa"); s != nil { + s.CurrentStage = "execute_script" + s.LastEventAt = nowMillis() - 3000 + } + case "grants": m.connected = true m.currentModel = "llama-cpp:default" diff --git a/apps/tui-go/internal/app/model.go b/apps/tui-go/internal/app/model.go index f4a71ae1..3b0356a0 100644 --- a/apps/tui-go/internal/app/model.go +++ b/apps/tui-go/internal/app/model.go @@ -65,8 +65,9 @@ const ( // RouterEntry is one line in a session's conversation transcript. type RouterEntry struct { - Role string // user | router | tool | narration | narration_llm + Role string // user | router | tool | narration | narration_llm | action Content string + Icon string // action role only: the gutter glyph (⏵ ✓ ✎ ⌘ ✕ ⊞ ⊟) Metrics *TurnMetrics } @@ -322,6 +323,10 @@ type Model struct { sbHidden map[string]bool sbIndex int + // actionsHidden mutes the inline action rows (tool calls / writes / approvals / grants) in + // the OUTPUT transcript; they always stay in the EVENTS panel. Persisted in tui-prefs.json. + actionsHidden bool + // standing grants (OverlayGrants) — active PROJECT/GLOBAL grants from the grant.list reply. grants []protocol.GrantDto grantsLoading bool @@ -385,6 +390,7 @@ func NewModel(client *ws.Client) Model { eventStripShown: true, transcriptSel: -1, sbHidden: loadStatusbarHidden(), + actionsHidden: loadPrefs().InlineActionsHidden, } } diff --git a/apps/tui-go/internal/app/prefs.go b/apps/tui-go/internal/app/prefs.go index 550616da..c6727065 100644 --- a/apps/tui-go/internal/app/prefs.go +++ b/apps/tui-go/internal/app/prefs.go @@ -9,7 +9,8 @@ import ( // prefs is the TUI's local, persisted preferences (display-only client state — kept out // of the server config, which is shared/replayed). Stored as JSON in the correx config dir. type prefs struct { - StatusbarHidden []string `json:"statusbarHidden"` + StatusbarHidden []string `json:"statusbarHidden"` + InlineActionsHidden bool `json:"inlineActionsHidden"` } // statusSegment is one toggleable status-bar segment. The always-on segments (correx label, @@ -37,44 +38,62 @@ func prefsPath() string { return filepath.Join(dir, "tui-prefs.json") } -// loadStatusbarHidden reads the persisted hidden-segment set (best-effort: a missing or -// corrupt file yields an empty set, i.e. everything shown). -func loadStatusbarHidden() map[string]bool { - out := map[string]bool{} +// loadPrefs reads the whole prefs file (best-effort: a missing or corrupt file yields the +// zero value, i.e. everything shown / actions visible). +func loadPrefs() prefs { + var p prefs path := prefsPath() if path == "" { - return out + return p } - data, err := os.ReadFile(path) - if err != nil { - return out + if data, err := os.ReadFile(path); err == nil { + _ = json.Unmarshal(data, &p) } - var p prefs - if json.Unmarshal(data, &p) == nil { - for _, id := range p.StatusbarHidden { - out[id] = true - } - } - return out + return p } -// saveStatusbarHidden writes the hidden set back to disk (best-effort; ignores IO errors so -// a read-only home never crashes the UI). Order follows statusSegments for a stable file. -func saveStatusbarHidden(hidden map[string]bool) { +// savePrefs writes the whole prefs file (best-effort; ignores IO errors so a read-only home +// never crashes the UI). Callers load, mutate one field, then save so nothing is clobbered. +func savePrefs(p prefs) { path := prefsPath() if path == "" { return } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return + } + if data, err := json.MarshalIndent(p, "", " "); err == nil { + _ = os.WriteFile(path, data, 0o644) + } +} + +// loadStatusbarHidden reads the persisted hidden-segment set (best-effort: a missing or +// corrupt file yields an empty set, i.e. everything shown). +func loadStatusbarHidden() map[string]bool { + out := map[string]bool{} + for _, id := range loadPrefs().StatusbarHidden { + out[id] = true + } + return out +} + +// saveStatusbarHidden writes the hidden set back to disk, preserving the other prefs fields. +// Order follows statusSegments for a stable file. +func saveStatusbarHidden(hidden map[string]bool) { var ids []string for _, seg := range statusSegments { if hidden[seg.id] { ids = append(ids, seg.id) } } - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - return - } - if data, err := json.MarshalIndent(prefs{StatusbarHidden: ids}, "", " "); err == nil { - _ = os.WriteFile(path, data, 0o644) - } + p := loadPrefs() + p.StatusbarHidden = ids + savePrefs(p) +} + +// saveInlineActionsHidden persists the inline-action-rows toggle, preserving other prefs fields. +func saveInlineActionsHidden(hidden bool) { + p := loadPrefs() + p.InlineActionsHidden = hidden + savePrefs(p) } diff --git a/apps/tui-go/internal/app/server.go b/apps/tui-go/internal/app/server.go index b3d7fc39..e4924787 100644 --- a/apps/tui-go/internal/app/server.go +++ b/apps/tui-go/internal/app/server.go @@ -198,6 +198,7 @@ func (m *Model) applyServer(msg protocol.ServerMessage) { } s.LastEventAt = nowMillis() } + m.appendAction(msg.SessionID, "⏵", msg.ToolName) case protocol.TypeToolCompleted: if s := m.session(msg.SessionID); s != nil { s.Active = false @@ -206,7 +207,13 @@ func (m *Model) applyServer(msg protocol.ServerMessage) { s.addEvent(msg.OccurredAt, "ToolCompleted", msg.ToolName) } if msg.Diff != nil && *msg.Diff != "" { + // A diff = a write/edit: name the file + the line delta, then keep the + // existing collapsed diff row (^x opens the full diff). + path, add, del := diffSummary(*msg.Diff) + m.appendAction(msg.SessionID, "✎", "wrote "+path+countSuffix(add, del)) m.appendRouter(msg.SessionID, RouterEntry{Role: "tool", Content: *msg.Diff}) + } else { + m.appendAction(msg.SessionID, "✓", actionToolText(msg.ToolName, msg.Summary)) } case protocol.TypeToolAssessed: if s := m.session(msg.SessionID); s != nil { @@ -224,11 +231,13 @@ func (m *Model) applyServer(msg protocol.ServerMessage) { s.markTool(msg.ToolName, ToolFailed) s.LastEventAt = msg.OccurredAt } + m.appendAction(msg.SessionID, "✗", actionToolText(msg.ToolName+" failed", msg.Reason)) case protocol.TypeToolRejected: if s := m.session(msg.SessionID); s != nil { s.markTool(msg.ToolName, ToolRejected) s.LastEventAt = nowMillis() } + m.appendAction(msg.SessionID, "✕", actionToolText(msg.ToolName+" blocked", msg.Reason)) case protocol.TypeArtifactCreated: if s := m.session(msg.SessionID); s != nil { s.addEvent(nowMillis(), "ArtifactCreated", msg.ArtifactID) @@ -254,6 +263,15 @@ func (m *Model) applyServer(msg protocol.ServerMessage) { m.onWorkflowProposed(msg) case protocol.TypeApprovalResolved: if s := m.session(msg.SessionID); s != nil { + // The resolved gate names no tool on the wire; recover it from the pending + // queue before dropping the gate, for the inline row. + tool := "" + for _, a := range s.PendingQueue { + if a.RequestID == msg.RequestID { + tool = a.ToolName + break + } + } // Drop just the resolved gate; any others stay queued and the band // advances to the next rather than vanishing entirely. s.removeApproval(msg.RequestID) @@ -263,6 +281,7 @@ func (m *Model) applyServer(msg protocol.ServerMessage) { } s.addEvent(msg.OccurredAt, "ApprovalResolved", detail) s.LastEventAt = nowMillis() + m.appendAction(msg.SessionID, approvalIcon(msg.Outcome), approvalActionText(msg.Outcome, tool, msg.Reason)) } case protocol.TypeSessionSnapshot: m.onSnapshot(msg) @@ -391,6 +410,73 @@ func sessionIDOf(msg protocol.ServerMessage) string { } } +// --- inline action-row helpers (external-feedback events surfaced in OUTPUT) --- + +// diffSummary extracts the written path and +/- line counts from a unified diff for the +// inline write row. Falls back to "file" when no `+++` header is present. +func diffSummary(diff string) (path string, added, removed int) { + path = "file" + for _, ln := range strings.Split(diff, "\n") { + switch { + case strings.HasPrefix(ln, "+++ "): + p := strings.TrimSpace(strings.TrimPrefix(ln, "+++ ")) + p = strings.TrimPrefix(p, "b/") + if p != "" && p != "/dev/null" { + path = p + } + case strings.HasPrefix(ln, "+") && !strings.HasPrefix(ln, "+++"): + added++ + case strings.HasPrefix(ln, "-") && !strings.HasPrefix(ln, "---"): + removed++ + } + } + return path, added, removed +} + +// countSuffix renders the " (+a −b)" delta, or "" when nothing changed. +func countSuffix(added, removed int) string { + if added == 0 && removed == 0 { + return "" + } + return " (+" + itoa(added) + " −" + itoa(removed) + ")" +} + +// actionToolText joins a tool label with a short, clipped result summary. +func actionToolText(label, summary string) string { + s := strings.TrimSpace(summary) + if s == "" { + return label + } + return label + " · " + clip(s, 48) +} + +func approvalIcon(outcome string) string { + if outcome == "REJECTED" { + return "✕" + } + return "⌘" +} + +// approvalActionText renders the inline approval row, noting an auto-approval that fired via a +// standing grant (reason "grant:"). +func approvalActionText(outcome, tool, reason string) string { + verb := "approved" + switch outcome { + case "REJECTED": + verb = "rejected" + case "AUTO_APPROVED": + verb = "auto-approved" + } + txt := verb + if tool != "" { + txt = verb + " " + tool + } + if strings.HasPrefix(reason, "grant:") { + txt += " · via grant" + } + return txt +} + // onSessionAnnounced fills in a session's workflow identity (the announce is the // only event carrying workflowId) and applies auto-focus. The session entry itself // was already created by the auto-vivify path in applyServer. diff --git a/apps/tui-go/internal/app/update.go b/apps/tui-go/internal/app/update.go index 1285d8bb..b807f9f0 100644 --- a/apps/tui-go/internal/app/update.go +++ b/apps/tui-go/internal/app/update.go @@ -760,7 +760,9 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { } case k.Type == tea.KeyEnter || runeIs(k, "x"): if m.grantIndex >= 0 && m.grantIndex < len(m.grants) { - m.client.Send(protocol.RevokeGrant(m.grants[m.grantIndex].GrantID)) + g := m.grants[m.grantIndex] + m.client.Send(protocol.RevokeGrant(g.GrantID)) + m.appendAction(m.selectedID, "⊟", "revoked "+g.ToolName+" · "+strings.ToLower(g.Scope)) // The server replies with a fresh grant.list, which refreshes m.grants. } case runeIs(k, "G"): @@ -829,6 +831,7 @@ func (m Model) applyGrantScope() (tea.Model, tea.Cmd) { scope := opts[idx].wire m.client.Send(protocol.CreateGrant(p.SessionID, scope, []string{p.Tier}, "granted via TUI ("+scope+")", p.ToolName)) m.client.Send(protocol.ApprovalResponse(p.RequestID, "APPROVE", nil)) + m.appendAction(p.SessionID, "⊞", "granted "+p.ToolName+" · "+strings.ToLower(scope)) if s := m.session(p.SessionID); s != nil { s.removeApproval(p.RequestID) } @@ -969,6 +972,7 @@ func paletteCommands() []paletteCmd { {"config", "g", "edit config", "view / change correx settings"}, {"grants", "G", "grants", "view / revoke standing grants"}, {"statusbar", "", "status bar", "show / hide status-bar segments"}, + {"actions", "", "inline actions", "show / hide tool & action rows in output"}, {"mode", "s", "toggle mode", "switch chat / steering"}, {"cancel", "c", "cancel session", "stop the selected session"}, {"back", "l", "back to list", "leave the current session"}, @@ -1016,6 +1020,8 @@ func (m Model) execPalette(id string) (tea.Model, tea.Cmd) { case "statusbar": m.overlay = OverlayStatusbar m.sbIndex = 0 + case "actions": + m.toggleInlineActions() case "mode": m.cycleChatMode() case "cancel": @@ -1288,6 +1294,22 @@ func (m *Model) appendRouter(sid string, e RouterEntry) { m.routerMessages[sid] = append(m.routerMessages[sid], e) } +// appendAction adds an inline "external feedback" row (a tool call, write/edit, approval, or +// grant) to a session's OUTPUT transcript, interleaved with chat turns by arrival order. The +// rows are always recorded; rendering is gated on actionsHidden so toggling reveals history. +func (m *Model) appendAction(sid, icon, text string) { + if sid == "" { + return + } + m.routerMessages[sid] = append(m.routerMessages[sid], RouterEntry{Role: "action", Icon: icon, Content: text}) +} + +// toggleInlineActions flips the inline-action-rows visibility and persists it. +func (m *Model) toggleInlineActions() { + m.actionsHidden = !m.actionsHidden + saveInlineActionsHidden(m.actionsHidden) +} + // transcriptNavUser moves the transcript selection to the previous (dir<0) or next // (dir>0) USER message — "go back to my previous message". From no selection, ctrl+↑ // grabs the most recent user message; ctrl+↓ past the last one drops back to tail-follow. diff --git a/apps/tui-go/internal/app/view.go b/apps/tui-go/internal/app/view.go index 2c81307b..954886c5 100644 --- a/apps/tui-go/internal/app/view.go +++ b/apps/tui-go/internal/app/view.go @@ -542,6 +542,14 @@ func (m Model) routerRows(w, h int) []string { } case "narration", "stage": rows = append(rows, t.span(e.Content, t.P.Dim)) + case "action": + // Inline "external feedback" row: a side-effecting action (tool call, write/edit, + // approval, grant) surfaced in the conversation flow. Muted via actionsHidden. + if m.actionsHidden { + break + } + icon := lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.Bg).Render(e.Icon) + rows = append(rows, " "+icon+" "+t.span(e.Content, t.P.Dim)) } } // With a selection, scroll so the selected message is visible (top-aligned, clamped From f14a83c0267b37a886267c80499c2dda98dd7b85 Mon Sep 17 00:00:00 2001 From: kami Date: Sun, 21 Jun 2026 23:24:10 +0000 Subject: [PATCH 050/107] feat(tui-go): drop inline tool-start rows (keep results only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ⏵ tool-start row doubled up with the ✓/✎ result row in OUTPUT. Drop it inline — tool starts still show in the tool list + EVENTS panel. The inline transcript now carries only outcomes: ✎ writes, ✓/✗ tool results, ⌘/✕ approvals, ⊞/⊟ grants. Co-Authored-By: Claude Opus 4.8 --- apps/tui-go/internal/app/demo.go | 2 -- apps/tui-go/internal/app/model.go | 2 +- apps/tui-go/internal/app/server.go | 3 ++- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/apps/tui-go/internal/app/demo.go b/apps/tui-go/internal/app/demo.go index c10be9dc..796a6104 100644 --- a/apps/tui-go/internal/app/demo.go +++ b/apps/tui-go/internal/app/demo.go @@ -173,10 +173,8 @@ func PreviewFrame(kind string, w, h int) string { m.routerMessages["04a546aa"] = []RouterEntry{ {Role: "user", Content: "run the healthcheck and write the script"}, {Role: "router", Content: "I'll create the script, then run it."}, - {Role: "action", Icon: "⏵", Content: "file_write"}, {Role: "action", Icon: "✎", Content: "wrote healthcheck.sh (+4 −0)"}, {Role: "action", Icon: "⌘", Content: "approved file_write"}, - {Role: "action", Icon: "⏵", Content: "shell"}, {Role: "action", Icon: "✓", Content: "shell · exit 0"}, {Role: "action", Icon: "⊞", Content: "granted file_write · global"}, {Role: "router", Content: "Done — script written and the healthcheck passes."}, diff --git a/apps/tui-go/internal/app/model.go b/apps/tui-go/internal/app/model.go index 3b0356a0..478a42bc 100644 --- a/apps/tui-go/internal/app/model.go +++ b/apps/tui-go/internal/app/model.go @@ -67,7 +67,7 @@ const ( type RouterEntry struct { Role string // user | router | tool | narration | narration_llm | action Content string - Icon string // action role only: the gutter glyph (⏵ ✓ ✎ ⌘ ✕ ⊞ ⊟) + Icon string // action role only: the gutter glyph (✓ ✎ ✗ ⌘ ✕ ⊞ ⊟) Metrics *TurnMetrics } diff --git a/apps/tui-go/internal/app/server.go b/apps/tui-go/internal/app/server.go index e4924787..6b5c0dc2 100644 --- a/apps/tui-go/internal/app/server.go +++ b/apps/tui-go/internal/app/server.go @@ -198,7 +198,8 @@ func (m *Model) applyServer(msg protocol.ServerMessage) { } s.LastEventAt = nowMillis() } - m.appendAction(msg.SessionID, "⏵", msg.ToolName) + // Tool-start is not surfaced inline (it doubles up with the ✓/✎ result row); it + // stays in the tool list + EVENTS panel. case protocol.TypeToolCompleted: if s := m.session(msg.SessionID); s != nil { s.Active = false From 531910d38a9edfb671512f17d6f4356b4eab9f7f Mon Sep 17 00:00:00 2001 From: kami Date: Mon, 22 Jun 2026 05:41:38 +0000 Subject: [PATCH 051/107] feat(tui-go): transcript free-scroll + help overlay + event filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three TUI-polish items. - OUTPUT free-scroll: PgUp/PgDn (and ^u/^d half-page) scroll the transcript back through history; esc snaps to tail-follow. outputScroll is clamped against outputViewport(), which mirrors View/renderMain geometry, so the edges land exactly (no dead presses unwinding an overshoot). routerRows split into buildTranscriptRows (full render) + windowing so the handler measures the same content. Tests cover the clamp + the fits-in-panel no-op. - Help overlay: `?` (or the "help" palette command) opens a keybinding cheat-sheet grouped by context (session / overlays / approval band) — the non-palette keys especially. Any key dismisses it. - Event-inspector filter: `/` filters the event list by a case-insensitive substring of type/detail (currentEvents applies it); `c` clears, esc stops editing then closes. Fresh per open. EVENTS side panel stays unfiltered. go build / vet / test green; rendered help + filtered inspector via cmd/preview (help, events-filter kinds). Co-Authored-By: Claude Opus 4.8 --- apps/tui-go/internal/app/demo.go | 21 +++ apps/tui-go/internal/app/model.go | 10 ++ .../tui-go/internal/app/output_scroll_test.go | 41 +++++ apps/tui-go/internal/app/overlays.go | 69 ++++++++- apps/tui-go/internal/app/update.go | 143 +++++++++++++++++- apps/tui-go/internal/app/view.go | 53 ++++--- 6 files changed, 309 insertions(+), 28 deletions(-) create mode 100644 apps/tui-go/internal/app/output_scroll_test.go diff --git a/apps/tui-go/internal/app/demo.go b/apps/tui-go/internal/app/demo.go index 796a6104..4da069c6 100644 --- a/apps/tui-go/internal/app/demo.go +++ b/apps/tui-go/internal/app/demo.go @@ -184,6 +184,27 @@ func PreviewFrame(kind string, w, h int) string { s.LastEventAt = nowMillis() - 3000 } + case "help": + m.connected = true + m.currentModel = "llama-cpp:default" + m.sessions = sampleSessions() + m.selectedID = "04a546aa" + m.sessionEntered = true + m.overlay = OverlayHelp + + case "events-filter": + m.connected = true + m.currentModel = "llama-cpp:default" + m.sessions = sampleSessions() + m.selectedID = "04a546aa" + m.sessionEntered = true + if s := m.session("04a546aa"); s != nil { + s.Events = sampleEvents() + } + m.overlay = OverlayEventInspector + m.eventFilter = "tool" + m.eventFilterTyping = true + case "grants": m.connected = true m.currentModel = "llama-cpp:default" diff --git a/apps/tui-go/internal/app/model.go b/apps/tui-go/internal/app/model.go index 478a42bc..37d240e4 100644 --- a/apps/tui-go/internal/app/model.go +++ b/apps/tui-go/internal/app/model.go @@ -61,6 +61,7 @@ const ( OverlayStatusbar OverlayGrants OverlayGrantScope + OverlayHelp ) // RouterEntry is one line in a session's conversation transcript. @@ -327,6 +328,15 @@ type Model struct { // the OUTPUT transcript; they always stay in the EVENTS panel. Persisted in tui-prefs.json. actionsHidden bool + // outputScroll is how many rows up from the bottom the OUTPUT transcript is scrolled + // (0 = tail-follow the newest output). PgUp/PgDn + ctrl+u/d move it; esc snaps back. + outputScroll int + + // event-inspector filter (OverlayEventInspector): narrows the event list by a substring + // of type/detail. eventFilterTyping is true while the operator is editing the query after /. + eventFilter string + eventFilterTyping bool + // standing grants (OverlayGrants) — active PROJECT/GLOBAL grants from the grant.list reply. grants []protocol.GrantDto grantsLoading bool diff --git a/apps/tui-go/internal/app/output_scroll_test.go b/apps/tui-go/internal/app/output_scroll_test.go new file mode 100644 index 00000000..240bfc82 --- /dev/null +++ b/apps/tui-go/internal/app/output_scroll_test.go @@ -0,0 +1,41 @@ +package app + +import "testing" + +// scrollOutput must clamp to [0, totalRows-panelHeight] against the same geometry the renderer +// windows to — so PgUp past the top and PgDn past the bottom land exactly on the edges (no +// dead presses unwinding an overshoot). +func TestOutputScrollClampsToBounds(t *testing.T) { + m := inSessionModel(120, 30) + msgs := make([]RouterEntry, 0, 60) + for i := 0; i < 60; i++ { + msgs = append(msgs, RouterEntry{Role: "router", Content: "a transcript line"}) + } + m.routerMessages[m.selectedID] = msgs + + w, h := m.outputViewport() + rows, _ := m.buildTranscriptRows(w) + max := len(rows) - h + if max <= 0 { + t.Fatalf("fixture transcript (%d rows) must exceed panel height (%d) to test scrolling", len(rows), h) + } + + m.scrollOutput(10_000) // way past the top + if m.outputScroll != max { + t.Fatalf("scroll up clamped to %d, want max %d", m.outputScroll, max) + } + m.scrollOutput(-10_000) // way past the bottom + if m.outputScroll != 0 { + t.Fatalf("scroll down clamped to %d, want 0 (tail-follow)", m.outputScroll) + } +} + +// A transcript that fits the panel never scrolls (stays tail-following). +func TestOutputScrollNoOpWhenFits(t *testing.T) { + m := inSessionModel(120, 30) + m.routerMessages[m.selectedID] = []RouterEntry{{Role: "router", Content: "one line"}} + m.scrollOutput(5) + if m.outputScroll != 0 { + t.Fatalf("short transcript should not scroll, got %d", m.outputScroll) + } +} diff --git a/apps/tui-go/internal/app/overlays.go b/apps/tui-go/internal/app/overlays.go index 38fc79ad..634d860d 100644 --- a/apps/tui-go/internal/app/overlays.go +++ b/apps/tui-go/internal/app/overlays.go @@ -132,6 +132,8 @@ func (m Model) renderOverlay(base string) string { return m.center(m.grantsModal()) case OverlayGrantScope: return m.center(m.grantScopeModal()) + case OverlayHelp: + return m.center(m.helpModal()) } return base } @@ -437,8 +439,25 @@ func (m Model) eventInspectorModal() string { b.WriteString(mbg(t, " ("+itoa(m.overlayEventIdx+1)+"/"+itoa(len(evs))+")", t.P.Faint)) } b.WriteString("\n\n") + // Filter line (shown while editing or when a query is set): a `/` prompt over the query. + if m.eventFilterTyping || m.eventFilter != "" { + caret := mbg(t, "", t.P.BgPanel) + if m.eventFilterTyping && m.caretVisible() { + caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▏") + } + prompt := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("/ ") + if m.eventFilter == "" { + b.WriteString(prompt + caret + mbg(t, "type to filter events…", t.P.Faint) + "\n\n") + } else { + b.WriteString(prompt + mbg(t, m.eventFilter, t.P.FgStrong) + caret + "\n\n") + } + } if len(evs) == 0 { - b.WriteString(mbg(t, "no events", t.P.Faint)) + msg := "no events" + if m.eventFilter != "" { + msg = "no events match \"" + m.eventFilter + "\"" + } + b.WriteString(mbg(t, msg, t.P.Faint)) return m.center(t.Overlay.Width(w).Render(b.String())) } // Budget the plain detail so the styled row never needs ANSI-aware truncation @@ -482,7 +501,7 @@ func (m Model) eventInspectorModal() string { mbg(t, " "+truncate(e.Detail, detailBudget), t.P.Dim) b.WriteString(row + "\n") } - b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "select"}, {"esc", "close"}})) + b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "select"}, {"/", "filter"}, {"c", "clear"}, {"esc", "close"}})) modal := t.Overlay.Width(w).Render(b.String()) return m.center(modal) @@ -688,6 +707,52 @@ func (m Model) grantScopeModal() string { return m.center(modal) } +// helpModal is the keybinding cheat-sheet (?), grouped by context. Any key dismisses it. +func (m Model) helpModal() string { + t := m.theme + w := m.modalWidth() + + type kb struct{ key, desc string } + section := func(b *strings.Builder, title string, rows []kb) { + b.WriteString(lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Bold(true).Render(title) + "\n") + for _, r := range rows { + key := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render(padRaw(r.key, 16)) + b.WriteString(" " + key + mbg(t, r.desc, t.P.Fg) + "\n") + } + b.WriteString("\n") + } + + var b strings.Builder + b.WriteString(m.titleLine("keybindings") + "\n\n") + section(&b, "session", []kb{ + {"i", "compose a message"}, + {"^↑ / ^↓", "jump to your previous / next message"}, + {"y", "copy the selected message (OSC52)"}, + {"PgUp / PgDn", "scroll output (^u / ^d half-page)"}, + {"esc", "drop selection / scroll → follow newest"}, + {"^x", "open the latest diff"}, + {"l", "back to the session list"}, + }) + section(&b, "overlays", []kb{ + {"p", "command palette (shows all shortcuts)"}, + {"e", "event inspector (/ to filter)"}, + {"t / v", "tools / artifacts"}, + {"S / R", "session stats / resume sessions"}, + {"m / g", "swap model / edit config"}, + {"G / I", "grants / idea board"}, + }) + section(&b, "approval band", []kb{ + {"y / a / enter", "approve once"}, + {"n / r", "reject"}, + {"e / s", "steer (add a note)"}, + {"A", "approve-always — choose scope"}, + {"↑ / ↓", "walk the pending queue"}, + }) + b.WriteString(modalHints(t, [][2]string{{"any key", "close"}})) + modal := t.Overlay.Width(w).Render(b.String()) + return m.center(modal) +} + func (m Model) toolPaletteModal() string { t := m.theme w := m.modalWidth() diff --git a/apps/tui-go/internal/app/update.go b/apps/tui-go/internal/app/update.go index b807f9f0..30cf2cea 100644 --- a/apps/tui-go/internal/app/update.go +++ b/apps/tui-go/internal/app/update.go @@ -231,14 +231,30 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { case tea.KeyEnter: return m.normalEnter() case tea.KeyEsc: - // In-session, esc first drops a transcript selection (back to tail-follow); - // otherwise it clears the session-list filter. + // In-session, esc first drops a transcript selection or output scroll (back to + // tail-follow); otherwise it clears the session-list filter. if m.transcriptSel >= 0 { m.transcriptSel = -1 return m, nil } + if m.outputScroll != 0 { + m.outputScroll = 0 + return m, nil + } m.filter = "" return m, nil + case tea.KeyPgUp: + m.scrollOutput(m.outputViewportPage()) + return m, nil + case tea.KeyPgDown: + m.scrollOutput(-m.outputViewportPage()) + return m, nil + case tea.KeyCtrlU: + m.scrollOutput(m.outputViewportPage() / 2) + return m, nil + case tea.KeyCtrlD: + m.scrollOutput(-m.outputViewportPage() / 2) + return m, nil case tea.KeyCtrlX: if m.currentDiff() != "" { m.overlay = OverlayDiff @@ -252,6 +268,8 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { switch string(k.Runes) { case "i": m.enterInsert(ModeRouter) + case "?": + m.overlay = OverlayHelp case "/": m.enterInsert(ModeFilter) case "j": @@ -261,8 +279,7 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { case "p": m.openPalette() case "e": - m.overlay = OverlayEventInspector - m.overlayEventIdx = 0 + m.openEventInspector() case "t": m.overlay = OverlayToolPalette case "v": @@ -600,6 +617,12 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { return m.handleSessionsKey(k) } if k.Type == tea.KeyEsc { + // In the event inspector, esc first stops an in-progress filter edit (keeping the + // query) rather than closing the overlay. + if m.overlay == OverlayEventInspector && m.eventFilterTyping { + m.eventFilterTyping = false + return m, nil + } m.overlay = OverlayNone return m, nil } @@ -621,7 +644,27 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { } case OverlayEventInspector: evs := m.currentEvents() + if m.eventFilterTyping { + switch { + case k.Type == tea.KeyEnter: + m.eventFilterTyping = false + case k.Type == tea.KeyBackspace: + if n := len(m.eventFilter); n > 0 { + m.eventFilter = m.eventFilter[:n-1] + m.overlayEventIdx = 0 + } + case k.Type == tea.KeyRunes || k.Type == tea.KeySpace: + m.eventFilter += string(k.Runes) + m.overlayEventIdx = 0 + } + return m, nil + } switch { + case runeIs(k, "/"): + m.eventFilterTyping = true + case runeIs(k, "c"): + m.eventFilter = "" + m.overlayEventIdx = 0 case k.Type == tea.KeyUp || runeIs(k, "k"): if m.overlayEventIdx > 0 { m.overlayEventIdx-- @@ -631,6 +674,9 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { m.overlayEventIdx++ } } + case OverlayHelp: + // Any key dismisses the cheat-sheet (esc handled above). + m.overlay = OverlayNone case OverlayToolPalette: if runeIs(k, "t") { m.overlay = OverlayNone @@ -973,6 +1019,7 @@ func paletteCommands() []paletteCmd { {"grants", "G", "grants", "view / revoke standing grants"}, {"statusbar", "", "status bar", "show / hide status-bar segments"}, {"actions", "", "inline actions", "show / hide tool & action rows in output"}, + {"help", "?", "help", "keybinding cheat-sheet"}, {"mode", "s", "toggle mode", "switch chat / steering"}, {"cancel", "c", "cancel session", "stop the selected session"}, {"back", "l", "back to list", "leave the current session"}, @@ -1005,8 +1052,7 @@ func (m Model) execPalette(id string) (tea.Model, tea.Cmd) { case "models": m.openModelsOverlay() case "events": - m.overlay = OverlayEventInspector - m.overlayEventIdx = 0 + m.openEventInspector() case "artifacts": m.openArtifacts() case "stats": @@ -1022,6 +1068,8 @@ func (m Model) execPalette(id string) (tea.Model, tea.Cmd) { m.sbIndex = 0 case "actions": m.toggleInlineActions() + case "help": + m.overlay = OverlayHelp case "mode": m.cycleChatMode() case "cancel": @@ -1310,6 +1358,64 @@ func (m *Model) toggleInlineActions() { saveInlineActionsHidden(m.actionsHidden) } +// scrollOutput moves the OUTPUT transcript by delta rows (positive = up / back through history), +// clamped to the transcript height against the same geometry the renderer uses. A 0 delta or a +// transcript shorter than the panel is a no-op (stays tail-following). +func (m *Model) scrollOutput(delta int) { + if delta == 0 { + return + } + w, h := m.outputViewport() + rows, _ := m.buildTranscriptRows(w) + max := len(rows) - h + if max < 0 { + max = 0 + } + m.outputScroll += delta + if m.outputScroll < 0 { + m.outputScroll = 0 + } + if m.outputScroll > max { + m.outputScroll = max + } +} + +// outputViewport mirrors View/renderMain to give the OUTPUT transcript's inner (w, h), so the +// scroll handler clamps against the exact geometry the renderer windows to. +func (m Model) outputViewport() (w, h int) { + bottomH := inputH + if m.displayState() == StateApproval { + bottomH = m.approvalBandHeight() + } + mainH := m.height - statusH - footerH - bottomH + if mainH < 3 { + mainH = 3 + } + if m.narrow() { + outH := mainH * 55 / 100 + if outH < 3 { + outH = 3 + } + evH := mainH - outH + if evH < 3 { + evH = 3 + outH = mainH - evH + } + return m.width - 4, outH - 2 + } + leftW := m.width * 63 / 100 + return leftW - 4, mainH - 2 +} + +// outputViewportPage is the PgUp/PgDn step (a panel minus one row of overlap for context). +func (m Model) outputViewportPage() int { + _, h := m.outputViewport() + if h < 2 { + return 1 + } + return h - 1 +} + // transcriptNavUser moves the transcript selection to the previous (dir<0) or next // (dir>0) USER message — "go back to my previous message". From no selection, ctrl+↑ // grabs the most recent user message; ctrl+↓ past the last one drops back to tail-follow. @@ -1401,9 +1507,30 @@ func (m Model) currentDiff() string { return "" } +// openEventInspector opens the event inspector with a fresh (cleared) filter. +func (m *Model) openEventInspector() { + m.overlay = OverlayEventInspector + m.overlayEventIdx = 0 + m.eventFilter = "" + m.eventFilterTyping = false +} + +// currentEvents is the selected session's events, narrowed by the inspector filter (a +// case-insensitive substring of type/detail) when one is set. func (m Model) currentEvents() []EventEntry { - if s := m.session(m.selectedID); s != nil { + s := m.session(m.selectedID) + if s == nil { + return nil + } + if m.eventFilter == "" { return s.Events } - return nil + q := strings.ToLower(m.eventFilter) + out := make([]EventEntry, 0, len(s.Events)) + for _, e := range s.Events { + if strings.Contains(strings.ToLower(e.Type+" "+e.Detail), q) { + out = append(out, e) + } + } + return out } diff --git a/apps/tui-go/internal/app/view.go b/apps/tui-go/internal/app/view.go index 954886c5..0aae1cd8 100644 --- a/apps/tui-go/internal/app/view.go +++ b/apps/tui-go/internal/app/view.go @@ -493,10 +493,42 @@ func (m Model) welcomeRows(w int) []string { func (m Model) routerRows(w, h int) []string { t := m.theme - msgs := m.routerMessages[m.selectedID] - if len(msgs) == 0 { + if len(m.routerMessages[m.selectedID]) == 0 { return []string{t.span("awaiting router response…", t.P.Faint)} } + rows, msgStart := m.buildTranscriptRows(w) + // With a selection, scroll so the selected message is visible (top-aligned, clamped to the + // end); otherwise honour the free-scroll offset (0 = tail-follow the newest output). + if m.transcriptSel >= 0 && m.transcriptSel < len(msgStart) && len(rows) > h { + start := msgStart[m.transcriptSel] + if start > len(rows)-h { + start = len(rows) - h + } + if start < 0 { + start = 0 + } + return rows[start : start+h] + } + if len(rows) > h { + off := m.outputScroll + if max := len(rows) - h; off > max { + off = max + } + if off < 0 { + off = 0 + } + end := len(rows) - off + return rows[end-h : end] + } + return rows +} + +// buildTranscriptRows renders the selected session's transcript to display rows (and records each +// message's first row index for scroll-to-selection). Split out so the scroll handler can measure +// the full height against the same content the renderer windows. +func (m Model) buildTranscriptRows(w int) ([]string, []int) { + t := m.theme + msgs := m.routerMessages[m.selectedID] var rows []string msgStart := make([]int, len(msgs)) // first row index of each message, for scroll-to-selection for mi, e := range msgs { @@ -552,22 +584,7 @@ func (m Model) routerRows(w, h int) []string { rows = append(rows, " "+icon+" "+t.span(e.Content, t.P.Dim)) } } - // With a selection, scroll so the selected message is visible (top-aligned, clamped - // to the end); otherwise tail-follow the newest output. - if m.transcriptSel >= 0 && m.transcriptSel < len(msgStart) && len(rows) > h { - start := msgStart[m.transcriptSel] - if start > len(rows)-h { - start = len(rows) - h - } - if start < 0 { - start = 0 - } - return rows[start : start+h] - } - if len(rows) > h { - rows = rows[len(rows)-h:] - } - return rows + return rows, msgStart } func (m Model) eventRows(w, h int) []string { From 08b5c63a0f717e9d25d82673c900a83800f2d746 Mon Sep 17 00:00:00 2001 From: kami Date: Mon, 22 Jun 2026 08:57:12 +0000 Subject: [PATCH 052/107] docs: reconcile BACKLOG against shipped work + gitignore tui preview binary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backlog hygiene per the file's own maintenance rule — resolved items get moved out, not left ticked-in-place. - Cut resolved body bullets from BACKLOG §A/B/C/D/E/G/I (health probes, static-first infra, CritiqueFinding, architect contradiction-check, A1/A2 gates, batch fetch-approval, source/egress events, render matrix, markdown, session list, idea promotion, turnId/probe/narration, .cs symbols, scene validator). All already archived in RETRO's burndown table. - Narrow partially-done items to their live follow-up rather than delete: B5 -> static_check stage emitter only; B6/C-A3 -> CritiqueOutcomeCorrelated producer only; C-A1 -> planner-prompt activation only. - Drop the stale "§E UNBLOCKED" index line (plan-comparison is BLOCKED, no PlanCandidate event; idea-promotion shipped f107ff5). - Archive the operator-requested TUI wave (palette, @ picker, CLAUDE.md L0 injection, inline action rows, free-scroll/help/filter) in RETRO 2026-06-22 — never BACKLOG items, would otherwise be orphaned in neither file. - gitignore apps/tui-go/preview (cmd/preview build artifact). Co-Authored-By: Claude Opus 4.8 --- BACKLOG.md | 98 ++++++++++++++++++------------------------ RETRO.md | 36 ++++++++++++++++ apps/tui-go/.gitignore | 1 + 3 files changed, 78 insertions(+), 57 deletions(-) diff --git a/BACKLOG.md b/BACKLOG.md index 61059635..69927603 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -23,8 +23,10 @@ against current code before acting (memory is point-in-time). ## ✅ RESOLVED 2026-06-20 → see `RETRO.md` (branch `feat/backlog-burndown`) -Built + unit-verified this pass (full detail + commit table in `RETRO.md`). The -matching bullets below are superseded; only the noted follow-ups remain live: +Built + unit-verified this pass (full detail + commit table in `RETRO.md`). **The matching +body bullets below have been cut** (hygiene reconcile 2026-06-22) — what remains in each +section is only the genuinely-live work and the narrowed follow-ups. This index stays as a +quick commit map: - **G** turnId `/repo`↔`/repo2` collision + WorkspaceStateProbe newline fingerprint — `784accb` - **I** `.cs`/Godot-Mono symbols — `f715ffa`; structural `.tscn`/`.tres` validator — `0e251d6` @@ -41,7 +43,6 @@ matching bullets below are superseded; only the noted follow-ups remain live: - **B §2** plan-derived diff manifest — `641051b` **Remaining (deeper / lower-verifiability):** H freestyle follow-ups (graph re-route, child-session runner — deep session-lifecycle, want live testing); the dormant model-dependent activations (A1 planner prompt, A3 producer, B§5 static_check stage — live-QA-gated, deprioritized to last). §E plan-comparison view is BLOCKED (no plan-candidate event; see §E). All cleanly unit-verifiable deterministic tracks are now landed. -**§E tui-go remaining (UNBLOCKED — Go via `GOTOOLCHAIN=auto` go1.24.2):** plan comparison view; idea-board→profile promotion. **Still blocked in this sandbox:** F live-QA gates + D §6 web approval client (need local model / SearXNG / GPU). **📋 Live-QA plans drafted** (`docs/qa/QA-*.md`, index `docs/qa/README.md`, env `docs/qa/ENV.md` + `scripts/qa/`): @@ -55,14 +56,9 @@ see RETRO 2026-06-21). These features stay listed until their plan **passes live ## A. Observability spec — `docs/plans/2026-06-13-observability-spec.md` Metrics + CLI + TUI pane and the health backbone are shipped (fe94140, 4f6bfa8, -f8fd260) — those are RETRO candidates once live-QA'd. Remaining: +f8fd260) — those are RETRO candidates once live-QA'd. The §4 LLAMA_SERVER + EVENT_STORE +health probes shipped (`266bbf0`, `64a90e7`) → see RETRO. Remaining: -- [x] **§4 LLAMA_SERVER health probe** — `266bbf0` (probe) + `64a90e7` (registered in the health - monitor, gated on a `llamacpp` provider URL, via `HttpLlamaLivenessClient`). Liveness is live; - tokens/sec degradation needs a throughput feed (the `/health` endpoint is reachability-only) — - that remainder noted in RETRO. → RETRO. -- [x] **§4 EVENT_STORE health probe** — append + fsync latency heartbeat. `266bbf0` - (`EventStoreLatencyProbe.forStore`, wired into the health monitor). → RETRO. - [ ] **§4/§3 live health TUI pane** — deferred to follow the metrics cadence (projection + CLI first, then pane). `correx health` CLI already exists. - [ ] **§3 tier-2 stats** — full session-completion summary pane polish (basic pane shipped). @@ -71,18 +67,17 @@ f8fd260) — those are RETRO candidates once live-QA'd. Remaining: ## B. Role reliability spec — `docs/plans/2026-06-13-role-reliability-spec.md` §3 analyst keystone (894969d), entity grounding (b050dc4), §2 write-manifest (4e5e4e5), -and §5 reviewer prompt/needs wiring (3467826) are shipped — RETRO candidates. Remaining: +§5 reviewer prompt/needs wiring (3467826), §5 static-first infra (`447fc7a`), §6 +`CritiqueFinding` type + calibration projection (`f783eed`), and §4 architect +contradiction-check (`eae0a0c`/`4e08510`) are shipped — see RETRO. Remaining: -- [ ] **§5 reviewer static-first INFRA** — run compiler/detekt/formatters as a pipeline - step and *mechanically exclude their findings* from reviewer context. Needs a - static-findings event representation. (Prompt half is done; infra half is unbuilt.) -- [ ] **§6 `CritiqueFinding` schema in code** — only test expectations exist today; no - structured type. Shared by plan critic + code reviewer. -- [ ] **§6 `CriticCalibrationProjection`** (keyed by modelHash, role) — needs runtime - history to exist first (spec ordering #5). Same projection as pipeline-addenda A3. -- [x] **§4 architect contradiction-check** — `eae0a0c` (checker) + `4e08510` (live server hook: - subscribe to architect `design` artifact → `check` → emit `PossibleContradictionFlaggedEvent`, - display-only). → RETRO. +- [ ] **§5 static_check stage (emitter)** — the runner/event/filter are built and live-wired + (`447fc7a`); what's missing is a deterministic workflow stage that actually runs + compiler/ktlint/detekt and emits `StaticFindingsRecordedEvent` (`// TODO(wiring)` in + `StaticAnalysisRunner.kt`). Needs an event-emitting stage-type seam. +- [ ] **§6 `CritiqueOutcomeCorrelatedEvent` producer** (= pipeline-addenda A3) — the + `CritiqueFinding` type + `CriticCalibrationProjection` exist (`f783eed`) but nothing feeds + them; needs a reviewer-loop producer that records finding outcomes. Build once, two consumers. - [ ] **§3 symbol grounding** — deferred; needs a real symbol index (file-path grounding done). - [ ] **§2 dynamic plan-derived diff manifest** — allowed files from the `impl_plan` artifact. Future refinement; static per-stage `writes = [globs]` form is shipped. @@ -93,16 +88,15 @@ and §5 reviewer prompt/needs wiring (3467826) are shipped — RETRO candidates. ## C. Plan pipeline addenda — `docs/plans/2026-06-13-plan-pipeline-addenda.md` -Entire spec is **unbuilt**. +Gate code is shipped: **A1** brief echo-back gate (`1df7af5`) and **A2** stage-level plan +checkpointing (`1a1b5cc`) → see RETRO. Remaining: -- [ ] **A1 brief echo-back gate** — planner restates brief as `BriefEcho`; harness diffs - referencedFiles/symbols/acceptanceCriteria vs original; divergence → `BriefEchoMismatchEvent`, - halt before any plan generation. (Cheapest failure point.) -- [ ] **A2 stage-level plan checkpointing** — reconciliation compares produced artifacts vs - the confirmed plan's produces-slots per stage; `StageCheckpointPassedEvent` / - `StageCheckpointFailedEvent` (halt + surface). -- [ ] **A3 critique calibration tracking** — `CritiqueOutcomeCorrelatedEvent` + - `CriticCalibrationProjection` (= role-reliability §6; build once, two consumers). +- [ ] **A1 activation** (model-dependent → live-QA) — `planner.md` must emit a `brief_echo` + block and `role_pipeline.toml` must set `briefEcho="true"` / `briefEchoSource` for the gate + to fire live. The gate (`BriefEchoMismatchEvent` + comparator/extractor) is already built. +- [ ] **A3 critique calibration tracking** — the `CriticCalibrationProjection` exists + (`f783eed`); only the `CritiqueOutcomeCorrelatedEvent` producer is missing. Tracked as the + §6 producer follow-up above (build once, two consumers). ## D. Research workflow spec — `docs/plans/2026-06-13-research-workflow-spec.md` @@ -115,10 +109,6 @@ web_search (T1) + web_fetch (T2) in `:infrastructure:tools`, `research.toml` - [ ] **§6 web approval client** — the one larger unbuilt piece. Third client on the existing Ktor REST+WS bus: approval cards + event strip + session list. Behind WireGuard/Tailscale only, never public. Independent track (spec ordering #5). -- [x] **§3 batch fetch-approval at source-list level** — `4b0024f`. `POST /sessions/{id}/approve-sources` - → one `EgressHostsGrantedEvent` for the whole list. → RETRO. -- [x] **Dedicated `SourceFetched` / `LowQualityExtraction` events** — `b098d87`. → RETRO. -- [x] **Dynamic per-session egress allowlist** — `027ff1f`, activated `ab7e4be`. → RETRO. - [ ] **Fresh-machine runtime install** — copy `research.toml` + `prompts/` + `schemas/` and the `[[artifacts]]` / `[tools.research]` config entries into `~/.config/correx` (already done on this box; repo source is `examples/workflows/` + `docs/schemas/`). @@ -126,25 +116,24 @@ web_search (T1) + web_fetch (T2) in `:infrastructure:tools`, `research.toml` ## E. TUI requirements spec — `docs/plans/2026-06-13-tui-requirements-spec.md` -§2 last-event clock + unknown-event fallback (8b896b5) and §5.1 deterministic command-parse -card (65df3f4) are shipped — RETRO candidates. Remaining: +§2 last-event clock + unknown-event fallback (8b896b5), §5.1 deterministic command-parse +card (65df3f4), §2 per-event render matrix + coverage guard (`2a99e15`), markdown chat +turns (`da72ee5`), session list / resume view (`1f7a5d9`), approval ergonomics +(`d5afcd3`), and idea-board → profile promotion (`f107ff5`) are shipped — see RETRO. +A second wave of operator-requested TUI work (command palette, @ file picker, CLAUDE.md +L0 injection, inline action rows, free-scroll/help/filter) is archived under RETRO +2026-06-22. Remaining: -- [ ] **§2 full Go↔Kotlin per-event-type render matrix** — golden-fixture tests asserting a - defined render for every event type (last §2 bullet; partially covered today). - 🚫 **§3/§4 plan comparison view** — **BLOCKED (no data source).** Top-2 candidates side-by-side, compact stage graphs, diff-highlighted, lint/critique findings inline. The server emits no plan-candidate event — only `workflow.proposed` (candidate *workflows*, not *plans*). Per the spec this "lands with the planning pipeline" (a multi-candidate planner), which is unbuilt, so there is no wire shape to render against and nothing to unit-verify. Do NOT build a blind TUI view; revisit only once a `PlanCandidate`-bearing event exists. (Checked 2026-06-21.) -- [ ] **§3 approval ergonomics audit** — single-key y/n/e for T0–T2, mandatory confirm T3+, - queued-approval navigable list (never modal-stacked). Verify against current band impl. -- [ ] **Render markdown in chat turns** (QA feature note b) — TUI currently ignores md. -- [ ] **Session list / resume view** (QA feature note c) — after server restart + TUI reopen - there's no way to see a prior session or its state; `GET /sessions` data exists server-side. -- [x] **Idea board → profile promotion** — `f107ff5`. `PromoteIdea` WS message → append idea as a - `conventions` entry in `.correx/project.toml` (`ProjectProfileWriter`), `IdeaPromotedEvent` - tombstones it off the board. → RETRO. +- [ ] **§3 approval ergonomics re-audit** — the spec'd band (single-key y/n/e for T0–T2, + mandatory T3+ confirm, navigable queue, never modal-stacked) shipped `d5afcd3`. Re-verify + only because the band has since gained the grant scope-picker (`A`) and inline action rows — + a cheap live look, not a build. - ⏸ **§5.2 command-card LLM annotation layer** — deferred by spec ("ship layer 1, live with it, then decide whether layer 2 earns its place"). Build only after using layer 1. @@ -166,13 +155,9 @@ These are SHIPPED in code but prompt-/server-/network-dependent and not yet live ## G. Low-severity hardening -- [ ] **turnId prefix collision** — `startsWith("repomap:$repoRoot")` can collide where one - root path prefixes another (`/repo` vs `/repo2`). Fix = trailing `:` delimiter in 3 - places (ProjectMemoryService write+check, L3RepoKnowledgeRetriever filter). -- [ ] **Narration lane lag** — stale pause-trigger narrations blend with current workflow - status; drop queued pause narrations once their approval resolves. -- [ ] **WorkspaceStateProbe fingerprint** — hashes `path|mtime|size` without newline separators - though the docstring says "lines". Internally consistent/deterministic; cosmetic. +The turnId `repomap:` prefix collision + WorkspaceStateProbe newline fingerprint (`784accb`) +and the narration-lane lag (`eff8f8d`) shipped — see RETRO. Remaining: + - ⚠️ **`PreemptRedirectEvent` has no producer** (QA bug #6) — B3 hard re-route is unreachable from the TUI. Intended flow: steering → router proposes → operator confirms. Skipped by design; build only if the hard-reroute path is actually wanted. @@ -188,10 +173,9 @@ These are SHIPPED in code but prompt-/server-/network-dependent and not yet live ## I. Gamedev / Godot readiness (operator intent) -- [ ] **`.cs` in `RepoMapIndexer` SYMBOL_PATTERNS** — add if the gamedev repo is Godot Mono - (`.gd` and `.md` headings already shipped: 81a21bf, f521ac8). -- [ ] **Structural `.tscn` / `.tres` validator** — parse scene format, node-tree-aware diff - preview. Prerequisite validation layer before any scene editing. +`.cs` / Godot-Mono symbols in `RepoMapIndexer` (`f715ffa`) and the structural `.tscn`/`.tres` +`SceneValidator` (`0e251d6`) shipped — see RETRO. Remaining: + - [ ] **(far future) Scene editing behind approval+diff** + derive a fine-tuning dataset from the event log. ⚠️ Fine-tuning is on CLAUDE.md's intentionally-NOT-implemented list — needs an explicit epic + operator de-listing, not an incidental add. diff --git a/RETRO.md b/RETRO.md index 536da916..ca92cfde 100644 --- a/RETRO.md +++ b/RETRO.md @@ -7,6 +7,42 @@ could not be run here. --- +## 2026-06-22 — BACKLOG hygiene reconcile + operator-requested TUI wave (branch `feat/backlog-burndown`) + +**Hygiene reconcile.** Per `BACKLOG.md`'s own maintenance rule, cut the resolved body bullets +that were left ticked-but-in-place after the 2026-06-20 burndown and 2026-06-21 grants work. +Sections affected: §A (health probes), §B (static-first infra, `CritiqueFinding`, architect +contradiction-check), §C (A1/A2 gate code), §D (batch fetch-approval, source events, dynamic +egress), §E (render matrix, markdown, session list, idea promotion), §G (turnId/probe/narration), +§I (`.cs` symbols, scene validator). Partially-done items were **narrowed to their live +follow-up** rather than deleted: §B5 → "static_check stage emitter only", §B6/§C-A3 → "the +`CritiqueOutcomeCorrelatedEvent` producer only", §C-A1 → "planner-prompt activation only". The +full commit map stays in the BACKLOG "✅ RESOLVED" index; detail is in the tables below. + +**Operator-requested TUI wave (archived here — never BACKLOG items, like the grants work).** +A run of TUI/UX features added on the branch after the burndown, each compiled + `go vet`/`go +test` green and rendered via `cmd/preview` before commit: + +| Commit | What | +|--------|------| +| `9143d55` | Global input history + stop idle redraws (copy-safe — no redraw churn while selecting text). | +| `85bfddf` | Slash-command menu in the chat input. | +| `2662156` | Transparent modal backdrops (overlays no longer paint an opaque box over the transcript). | +| `ba7ac06` | Transcript message-jump (`^↑`/`^↓` to your prev/next message) + OSC52 clipboard copy. | +| `65e5d88` | **Server:** `ListFiles`/`FileList` protocol backing the TUI `@` picker. | +| `04d7e26` | `@` file-reference picker in the input. | +| `c616982` | **Context:** inject `CLAUDE.md` / `AGENTS.md` as L0 standing context. | +| `54f94a5` | Command-palette keybinds + configurable (hideable) status bar. | +| `6a06f2e` | Inline action rows in the OUTPUT transcript (tool results / approvals / writes / grants as `RouterEntry` role `action`, interleaved by arrival). | +| `f14a83c` | Drop the inline tool-*start* rows (they doubled up with the result rows). | +| `531910d` | Transcript free-scroll (`PgUp/PgDn`, `^u/^d`, `esc` snaps to tail-follow; clamped via `outputViewport()`/`buildTranscriptRows`, 2 tests) + help overlay (`?`) + event-inspector `/` filter. | + +These are mostly deterministic render/UX changes (verifiable by `go test` + `cmd/preview`); the +only remaining live look is the §E approval-ergonomics re-audit noted in BACKLOG (the band gained +the grant scope-picker + action rows since `d5afcd3`). + +--- + ## 2026-06-21 — wider grants + revoke (branch `feat/backlog-burndown`) Operator-requested (not a prior BACKLOG item): grants that outlive a single session, plus a diff --git a/apps/tui-go/.gitignore b/apps/tui-go/.gitignore index fefb4607..9ec7c9a2 100644 --- a/apps/tui-go/.gitignore +++ b/apps/tui-go/.gitignore @@ -1 +1,2 @@ /tui-go +/preview From db27b3436b749004a92ae44240ac0bb8afbcdef0 Mon Sep 17 00:00:00 2001 From: kami Date: Mon, 22 Jun 2026 09:09:54 +0000 Subject: [PATCH 053/107] feat(tui-go): page-scroll the diff/preview/command fullscreen viewer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ^x fullscreen viewer (OverlayDiff) backs every tool preview — split diff for write/edit, the command card for shell, plain lines otherwise, plus any transcript tool-output — but only scrolled one line per keypress, making a long diff tedious to read. - Add paging to the viewer, matching the OUTPUT free-scroll contract: ↑↓/jk by line, PgUp/PgDn by a full body, ^u/^d by half, g/G to the ends. New scrollDiff() clamps to [0, diffMaxScroll] so paging past either edge lands exactly (no dead presses). diff_scroll_test.go (2): clamp + no-op-when-fits. - Update the modal hint to advertise paging (line / page / ends / close) and add a "diff / preview viewer (^x)" section to the ? help overlay. Also note the T3+ confirm-again behaviour in the approval-band help. Approval ergonomics re-audit (BACKLOG §E): the spec'd band still holds — single-key y/n/e for T0-T2, mandatory second-key confirm for T3+ (HighTier), navigable queue with an i/n indicator, docked band never modal-stacked; all covered green by approval_test.go. The scroll granularity above was the only gap, now closed. Co-Authored-By: Claude Opus 4.8 --- apps/tui-go/internal/app/diff_scroll_test.go | 46 ++++++++++++++++++++ apps/tui-go/internal/app/overlays.go | 27 ++++++++++-- apps/tui-go/internal/app/update.go | 24 +++++++--- 3 files changed, 88 insertions(+), 9 deletions(-) create mode 100644 apps/tui-go/internal/app/diff_scroll_test.go diff --git a/apps/tui-go/internal/app/diff_scroll_test.go b/apps/tui-go/internal/app/diff_scroll_test.go new file mode 100644 index 00000000..7e1e726a --- /dev/null +++ b/apps/tui-go/internal/app/diff_scroll_test.go @@ -0,0 +1,46 @@ +package app + +import ( + "strings" + "testing" +) + +// scrollDiff must clamp to [0, diffMaxScroll] against the same geometry the modal renders to, +// so PgUp/PgDn (and g/G) past either end land exactly on the edge — no dead presses unwinding +// an overshoot. Backs the fullscreen viewer for every tool preview (diff / command / plain). +func TestDiffScrollClampsToBounds(t *testing.T) { + m := inSessionModel(120, 30) + var b strings.Builder + b.WriteString("--- a/f\n+++ b/f\n@@ -0,0 +1,40 @@\n") + for i := 0; i < 40; i++ { + b.WriteString("+a new line\n") + } + // currentDiff() falls back to the last "tool" transcript entry when no approval is pending. + m.routerMessages[m.selectedID] = []RouterEntry{{Role: "tool", Content: b.String()}} + + max := m.diffMaxScroll() + if max <= 0 { + t.Fatalf("fixture diff must exceed the modal body height to test scrolling; max=%d", max) + } + + m.scrollDiff(10_000) // way past the bottom + if m.diffScrollOffset != max { + t.Fatalf("scroll past end clamped to %d, want max %d", m.diffScrollOffset, max) + } + m.scrollDiff(-10_000) // way past the top + if m.diffScrollOffset != 0 { + t.Fatalf("scroll past top clamped to %d, want 0", m.diffScrollOffset) + } +} + +// A diff that fits the modal body never scrolls. +func TestDiffScrollNoOpWhenFits(t *testing.T) { + m := inSessionModel(120, 30) + m.routerMessages[m.selectedID] = []RouterEntry{ + {Role: "tool", Content: "--- a/f\n+++ b/f\n@@ -1,1 +1,1 @@\n-x\n+y\n"}, + } + m.scrollDiff(5) + if m.diffScrollOffset != 0 { + t.Fatalf("short diff should not scroll, got %d", m.diffScrollOffset) + } +} diff --git a/apps/tui-go/internal/app/overlays.go b/apps/tui-go/internal/app/overlays.go index 634d860d..41d3a214 100644 --- a/apps/tui-go/internal/app/overlays.go +++ b/apps/tui-go/internal/app/overlays.go @@ -353,6 +353,20 @@ func (m Model) diffMaxScroll() int { return max } +// scrollDiff moves the diff/preview/command modal offset by delta rows, clamped to +// [0, diffMaxScroll] so paging past either end lands exactly on the edge (no dead +// presses unwinding an overshoot — same contract as the OUTPUT free-scroll). +func (m *Model) scrollDiff(delta int) { + off := m.diffScrollOffset + delta + if mx := m.diffMaxScroll(); off > mx { + off = mx + } + if off < 0 { + off = 0 + } + m.diffScrollOffset = off +} + func (m Model) diffModal() string { if cmd := m.pendingCommand(); cmd != nil { return m.commandModal(cmd) @@ -390,7 +404,7 @@ func (m Model) diffModal() string { for _, ln := range lines { b.WriteString(ln + "\n") } - b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "scroll"}, {"^x/esc", "close"}})) + b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "line"}, {"PgUp/Dn", "page"}, {"g/G", "ends"}, {"^x/esc", "close"}})) modal := t.Overlay.Width(w).Render(b.String()) return m.center(modal) @@ -424,7 +438,7 @@ func (m Model) commandModal(a *Approval) string { for i := off; i < end; i++ { b.WriteString(lines[i] + "\n") } - b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "scroll"}, {"^x/esc", "close"}})) + b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "line"}, {"PgUp/Dn", "page"}, {"g/G", "ends"}, {"^x/esc", "close"}})) return m.center(t.Overlay.Width(w).Render(b.String())) } @@ -742,11 +756,18 @@ func (m Model) helpModal() string { {"G / I", "grants / idea board"}, }) section(&b, "approval band", []kb{ - {"y / a / enter", "approve once"}, + {"y / a / enter", "approve once (T3+ asks again to confirm)"}, {"n / r", "reject"}, {"e / s", "steer (add a note)"}, {"A", "approve-always — choose scope"}, {"↑ / ↓", "walk the pending queue"}, + {"^x", "fullscreen the diff / command preview"}, + }) + section(&b, "diff / preview viewer (^x)", []kb{ + {"↑ / ↓", "scroll a line"}, + {"PgUp / PgDn", "scroll a page (^u / ^d half)"}, + {"g / G", "jump to top / end"}, + {"^x / esc", "close"}, }) b.WriteString(modalHints(t, [][2]string{{"any key", "close"}})) modal := t.Overlay.Width(w).Render(b.String()) diff --git a/apps/tui-go/internal/app/update.go b/apps/tui-go/internal/app/update.go index 30cf2cea..14946733 100644 --- a/apps/tui-go/internal/app/update.go +++ b/apps/tui-go/internal/app/update.go @@ -630,15 +630,27 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { case OverlayPalette: return m.handlePaletteKey(k) case OverlayDiff: + // A long diff/preview is unreadable one line at a time, so the fullscreen + // viewer pages like the OUTPUT transcript: ↑↓/jk by line, PgUp/PgDn by a + // full body, ^u/^d by half, g/G to the ends. All clamp to the diff bounds. + page := m.diffBodyHeight() switch { case k.Type == tea.KeyUp || runeIs(k, "k"): - if m.diffScrollOffset > 0 { - m.diffScrollOffset-- - } + m.scrollDiff(-1) case k.Type == tea.KeyDown || runeIs(k, "j"): - if m.diffScrollOffset < m.diffMaxScroll() { - m.diffScrollOffset++ - } + m.scrollDiff(1) + case k.Type == tea.KeyPgUp: + m.scrollDiff(-page) + case k.Type == tea.KeyPgDown: + m.scrollDiff(page) + case k.Type == tea.KeyCtrlU: + m.scrollDiff(-page / 2) + case k.Type == tea.KeyCtrlD: + m.scrollDiff(page / 2) + case runeIs(k, "g"): + m.diffScrollOffset = 0 + case runeIs(k, "G"): + m.diffScrollOffset = m.diffMaxScroll() case k.Type == tea.KeyCtrlX: m.overlay = OverlayNone } From f043640373b16b9a392dd75fd9ee0386f63d0f4d Mon Sep 17 00:00:00 2001 From: kami Date: Mon, 22 Jun 2026 09:10:39 +0000 Subject: [PATCH 054/107] =?UTF-8?q?docs:=20close=20=C2=A7E=20approval-ergo?= =?UTF-8?q?nomics=20re-audit=20(move=20to=20RETRO)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The re-audit passed and its one gap — line-only diff-viewer scroll — was fixed in db27b34. Cut the BACKLOG §E item and record the result + the diff-viewer paging commit in RETRO 2026-06-22. Co-Authored-By: Claude Opus 4.8 --- BACKLOG.md | 4 ---- RETRO.md | 11 ++++++++--- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/BACKLOG.md b/BACKLOG.md index 69927603..8524cad1 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -130,10 +130,6 @@ L0 injection, inline action rows, free-scroll/help/filter) is archived under RET spec this "lands with the planning pipeline" (a multi-candidate planner), which is unbuilt, so there is no wire shape to render against and nothing to unit-verify. Do NOT build a blind TUI view; revisit only once a `PlanCandidate`-bearing event exists. (Checked 2026-06-21.) -- [ ] **§3 approval ergonomics re-audit** — the spec'd band (single-key y/n/e for T0–T2, - mandatory T3+ confirm, navigable queue, never modal-stacked) shipped `d5afcd3`. Re-verify - only because the band has since gained the grant scope-picker (`A`) and inline action rows — - a cheap live look, not a build. - ⏸ **§5.2 command-card LLM annotation layer** — deferred by spec ("ship layer 1, live with it, then decide whether layer 2 earns its place"). Build only after using layer 1. diff --git a/RETRO.md b/RETRO.md index ca92cfde..8ddb0d3d 100644 --- a/RETRO.md +++ b/RETRO.md @@ -36,10 +36,15 @@ test` green and rendered via `cmd/preview` before commit: | `6a06f2e` | Inline action rows in the OUTPUT transcript (tool results / approvals / writes / grants as `RouterEntry` role `action`, interleaved by arrival). | | `f14a83c` | Drop the inline tool-*start* rows (they doubled up with the result rows). | | `531910d` | Transcript free-scroll (`PgUp/PgDn`, `^u/^d`, `esc` snaps to tail-follow; clamped via `outputViewport()`/`buildTranscriptRows`, 2 tests) + help overlay (`?`) + event-inspector `/` filter. | +| `db27b34` | Page-scroll the `^x` diff/preview/command fullscreen viewer (`↑↓` line, `PgUp/PgDn` page, `^u/^d` half, `g/G` ends; `scrollDiff()` clamp, 2 tests) + advertise it in the modal hint and the `?` help overlay. Closes the §E approval-ergonomics re-audit. | -These are mostly deterministic render/UX changes (verifiable by `go test` + `cmd/preview`); the -only remaining live look is the §E approval-ergonomics re-audit noted in BACKLOG (the band gained -the grant scope-picker + action rows since `d5afcd3`). +These are mostly deterministic render/UX changes (verifiable by `go test` + `cmd/preview`). + +**§E approval-ergonomics re-audit — DONE (`db27b34`).** Re-checked the band after it gained the +grant scope-picker (`A`) and inline action rows: the spec still holds — single-key `y/n/e` for +T0–T2, mandatory second-key confirm for T3+ (`HighTier`), navigable queue with an `i/n` indicator, +docked band never modal-stacks. All covered green by `approval_test.go`. The one gap was the diff +viewer scrolling one line at a time; `db27b34` adds paging. Item moved out of BACKLOG. --- From 7114bb7658204e102f7dd75797cd96986c3ebba0 Mon Sep 17 00:00:00 2001 From: kami Date: Mon, 22 Jun 2026 09:26:21 +0000 Subject: [PATCH 055/107] feat(tui-go): scroll tall modals instead of clipping them off-screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ? help and S stats modals rendered at fixed height; compositeOverlay drops any rows past the screen, so on a 24-30 row terminal their bottom sections — including the close hint — vanished with no way to reach them (and the backdrop border corrupted). Recent help additions worsened it. - Add a shared scrollable-modal renderer: helpBody()/statsBody() produce discrete lines, renderScrollModal() windows them by m.modalScroll (clamped to the viewport via modalBodyRows/scrollableModalMax) and pins the hint, so the box never exceeds the screen. A "(off-end/total)" indicator shows in the title when scrolled. Keys: ↑↓/jk line, PgUp/PgDn page, ^u/^d half, g/G ends; any other key still dismisses help. modal_scroll_test.go (2): clamp + no-op. - Page the artifacts viewer (v) too: PgUp/PgDn were one line at a time — now a full body, with ^u/^d half and g/G ends via scrollArtifact(). Verified via cmd/preview: help/stats close hints now survive at h=20-28 (were clipped below ~32/40) and show the scroll indicator; full at tall heights. Co-Authored-By: Claude Opus 4.8 --- apps/tui-go/internal/app/modal_scroll_test.go | 39 ++++ apps/tui-go/internal/app/model.go | 1 + apps/tui-go/internal/app/overlays.go | 184 ++++++++++++++---- apps/tui-go/internal/app/update.go | 65 ++++++- 4 files changed, 242 insertions(+), 47 deletions(-) create mode 100644 apps/tui-go/internal/app/modal_scroll_test.go diff --git a/apps/tui-go/internal/app/modal_scroll_test.go b/apps/tui-go/internal/app/modal_scroll_test.go new file mode 100644 index 00000000..51a85370 --- /dev/null +++ b/apps/tui-go/internal/app/modal_scroll_test.go @@ -0,0 +1,39 @@ +package app + +import "testing" + +// scrollModal must clamp the body offset of a tall fixed-content modal (help / stats) to +// [0, scrollableModalMax] so paging past either end lands exactly — the modal scrolls +// instead of clipping its bottom (incl. the close hint) off-screen. +func TestModalScrollClampsToBounds(t *testing.T) { + m := inSessionModel(120, 24) // short enough that the help cheat-sheet overflows + m.overlay = OverlayHelp + + body := m.activeModalBody() + max := m.scrollableModalMax(len(body)) + if max <= 0 { + t.Fatalf("help body (%d lines) must exceed the modal viewport (%d) to test scrolling", len(body), m.modalBodyRows()) + } + + m.scrollModal(10_000) // past the bottom + if m.modalScroll != max { + t.Fatalf("scroll past end clamped to %d, want max %d", m.modalScroll, max) + } + m.scrollModal(-10_000) // past the top + if m.modalScroll != 0 { + t.Fatalf("scroll past top clamped to %d, want 0", m.modalScroll) + } +} + +// A modal that fits the screen never scrolls. +func TestModalScrollNoOpWhenFits(t *testing.T) { + m := inSessionModel(120, 60) // tall enough that help fits in full + m.overlay = OverlayHelp + if max := m.scrollableModalMax(len(m.activeModalBody())); max != 0 { + t.Fatalf("help should fit at height 60, got max scroll %d", max) + } + m.scrollModal(5) + if m.modalScroll != 0 { + t.Fatalf("a modal that fits should not scroll, got %d", m.modalScroll) + } +} diff --git a/apps/tui-go/internal/app/model.go b/apps/tui-go/internal/app/model.go index 37d240e4..2289bef7 100644 --- a/apps/tui-go/internal/app/model.go +++ b/apps/tui-go/internal/app/model.go @@ -271,6 +271,7 @@ type Model struct { overlay OverlayKind overlayEventIdx int diffScrollOffset int + modalScroll int // body scroll for tall fixed-content modals (help, stats) eventStripShown bool // artifact viewer (OverlayArtifacts) — populated by the artifact.list response diff --git a/apps/tui-go/internal/app/overlays.go b/apps/tui-go/internal/app/overlays.go index 41d3a214..503c616b 100644 --- a/apps/tui-go/internal/app/overlays.go +++ b/apps/tui-go/internal/app/overlays.go @@ -722,23 +722,103 @@ func (m Model) grantScopeModal() string { } // helpModal is the keybinding cheat-sheet (?), grouped by context. Any key dismisses it. -func (m Model) helpModal() string { +// modalBodyRows is how many body lines a scrollable fixed-content modal shows: the +// screen height minus the modal chrome (border 2 + padding 2 + title block 2 + hint +// block 2). Sized so a modal that fits shows in full and a taller one scrolls rather +// than clipping its bottom off-screen (the old compositeOverlay behaviour). +func (m Model) modalBodyRows() int { + if r := m.height - 8; r > 3 { + return r + } + return 3 +} + +// scrollableModalMax is the largest scroll offset for a body of n lines, anchoring the +// last page to the bottom. +func (m Model) scrollableModalMax(n int) int { + if max := n - m.modalBodyRows(); max > 0 { + return max + } + return 0 +} + +// activeModalBody returns the body lines of whichever scrollable fixed-content modal is +// open, so the key handler can clamp m.modalScroll against the same content it renders. +func (m Model) activeModalBody() []string { + switch m.overlay { + case OverlayHelp: + return m.helpBody() + case OverlayStats: + if m.statsLoading || m.stats == nil || m.statsFor != m.selectedID { + return nil + } + return m.statsBody() + } + return nil +} + +// scrollModal moves the active fixed-content modal's body offset by delta lines, clamped +// to [0, scrollableModalMax] — same contract as the diff/output scrollers. +func (m *Model) scrollModal(delta int) { + max := m.scrollableModalMax(len(m.activeModalBody())) + off := m.modalScroll + delta + if off > max { + off = max + } + if off < 0 { + off = 0 + } + m.modalScroll = off +} + +// renderScrollModal lays out a title + scrollable body + pinned hint inside the modal box, +// windowing body by m.modalScroll so the box never exceeds the screen. A position indicator +// appears in the title row whenever the body is taller than the viewport. +func (m Model) renderScrollModal(title, titleSuffix string, body []string, hint [][2]string) string { t := m.theme w := m.modalWidth() + rows := m.modalBodyRows() + off := m.modalScroll + if mx := m.scrollableModalMax(len(body)); off > mx { + off = mx + } + if off < 0 { + off = 0 + } + end := off + rows + if end > len(body) { + end = len(body) + } + var b strings.Builder + b.WriteString(m.titleLine(title)) + if titleSuffix != "" { + b.WriteString(mbg(t, titleSuffix, t.P.Faint)) + } + if len(body) > rows { + b.WriteString(mbg(t, " ("+itoa(off+1)+"-"+itoa(end)+"/"+itoa(len(body))+")", t.P.Faint)) + } + b.WriteString("\n\n") + for i := off; i < end; i++ { + b.WriteString(body[i] + "\n") + } + b.WriteString("\n" + modalHints(t, hint)) + return t.Overlay.Width(w).Render(b.String()) +} +// helpBody is the keybinding cheat-sheet as discrete lines (so renderScrollModal can window it). +func (m Model) helpBody() []string { + t := m.theme type kb struct{ key, desc string } - section := func(b *strings.Builder, title string, rows []kb) { - b.WriteString(lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Bold(true).Render(title) + "\n") + var out []string + section := func(title string, rows []kb) { + out = append(out, lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Bold(true).Render(title)) for _, r := range rows { key := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render(padRaw(r.key, 16)) - b.WriteString(" " + key + mbg(t, r.desc, t.P.Fg) + "\n") + out = append(out, " "+key+mbg(t, r.desc, t.P.Fg)) } - b.WriteString("\n") + out = append(out, "") } - - var b strings.Builder - b.WriteString(m.titleLine("keybindings") + "\n\n") - section(&b, "session", []kb{ + section("session", []kb{ {"i", "compose a message"}, {"^↑ / ^↓", "jump to your previous / next message"}, {"y", "copy the selected message (OSC52)"}, @@ -747,7 +827,7 @@ func (m Model) helpModal() string { {"^x", "open the latest diff"}, {"l", "back to the session list"}, }) - section(&b, "overlays", []kb{ + section("overlays", []kb{ {"p", "command palette (shows all shortcuts)"}, {"e", "event inspector (/ to filter)"}, {"t / v", "tools / artifacts"}, @@ -755,7 +835,7 @@ func (m Model) helpModal() string { {"m / g", "swap model / edit config"}, {"G / I", "grants / idea board"}, }) - section(&b, "approval band", []kb{ + section("approval band", []kb{ {"y / a / enter", "approve once (T3+ asks again to confirm)"}, {"n / r", "reject"}, {"e / s", "steer (add a note)"}, @@ -763,15 +843,18 @@ func (m Model) helpModal() string { {"↑ / ↓", "walk the pending queue"}, {"^x", "fullscreen the diff / command preview"}, }) - section(&b, "diff / preview viewer (^x)", []kb{ + section("diff / preview viewer (^x)", []kb{ {"↑ / ↓", "scroll a line"}, {"PgUp / PgDn", "scroll a page (^u / ^d half)"}, {"g / G", "jump to top / end"}, {"^x / esc", "close"}, }) - b.WriteString(modalHints(t, [][2]string{{"any key", "close"}})) - modal := t.Overlay.Width(w).Render(b.String()) - return m.center(modal) + return out +} + +func (m Model) helpModal() string { + return m.renderScrollModal("keybindings", "", m.helpBody(), + [][2]string{{"↑↓", "scroll"}, {"esc", "close"}}) } func (m Model) toolPaletteModal() string { @@ -883,6 +966,20 @@ func (m Model) artifactContentMaxScroll(w int) int { return max } +// scrollArtifact moves the selected artifact's content offset by delta lines, clamped to +// the scrollable range (same contract as the diff/output scrollers). +func (m *Model) scrollArtifact(delta int) { + max := m.artifactContentMaxScroll(m.modalWidth() - 6) + off := m.artifactScroll + delta + if off > max { + off = max + } + if off < 0 { + off = 0 + } + m.artifactScroll = off +} + func (m Model) artifactsModal() string { t := m.theme w := m.modalWidth() @@ -961,7 +1058,7 @@ func (m Model) artifactsModal() string { b.WriteString(mbg(t, " "+lines[i], t.P.Fg) + "\n") } - b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "select"}, {"PgUp/Dn", "scroll"}, {"v/esc", "close"}})) + b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "select"}, {"PgUp/Dn", "page"}, {"g/G", "ends"}, {"v/esc", "close"}})) return t.Overlay.Width(w).Render(b.String()) } @@ -993,14 +1090,14 @@ func (m Model) statsModal() string { t := m.theme w := m.modalWidth() - var b strings.Builder - b.WriteString(m.titleLine("session stats")) - if m.selectedID != "" { - b.WriteString(mbg(t, " ("+shortID(m.selectedID)+")", t.P.Faint)) - } - b.WriteString("\n\n") - + // Loading / empty: a small fixed modal, no scroll needed. if m.statsLoading || m.stats == nil || m.statsFor != m.selectedID { + var b strings.Builder + b.WriteString(m.titleLine("session stats")) + if m.selectedID != "" { + b.WriteString(mbg(t, " ("+shortID(m.selectedID)+")", t.P.Faint)) + } + b.WriteString("\n\n") msg := "loading…" if !m.statsLoading && m.stats == nil { msg = "no stats for this session" @@ -1010,15 +1107,29 @@ func (m Model) statsModal() string { return t.Overlay.Width(w).Render(b.String()) } + suffix := "" + if m.selectedID != "" { + suffix = " (" + shortID(m.selectedID) + ")" + } + return m.renderScrollModal("session stats", suffix, m.statsBody(), + [][2]string{{"↑↓", "scroll"}, {"S/esc", "close"}}) +} + +// statsBody is the session-stats report as discrete lines (so renderScrollModal can window it). +// Callers must ensure m.stats is loaded for the selected session. +func (m Model) statsBody() []string { + t := m.theme s := m.stats - cw := w - 4 // modal inner content width (borders + padding) + cw := m.modalWidth() - 4 // modal inner content width (borders + padding) + var out []string section := func(label string) string { return lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Bold(true).Render(label) } // put clips each data line to the inner width so it never wraps on a slim modal. - put := func(styled string) { b.WriteString(clip(styled, cw) + "\n") } + put := func(styled string) { out = append(out, clip(styled, cw)) } line := func(s string) string { return mbg(t, s, t.P.Fg) } faint := func(s string) string { return mbg(t, s, t.P.Faint) } + blank := func() { out = append(out, "") } // nameW shrinks the breakdown name column on slim terminals. nameW := 20 if cw < 52 { @@ -1028,10 +1139,10 @@ func (m Model) statsModal() string { // header row: duration + event count put(faint(" duration ") + line(humanDurMs(s.SessionDurationMs)) + faint(" events ") + line(itoa(int(s.EventCount)))) - b.WriteString("\n") + blank() // inference - b.WriteString(section("Inference") + "\n") + out = append(out, section("Inference")) put(line(fmt.Sprintf(" %d calls · %d+%d tok · %.1f tok/s", s.InferenceCount, s.PromptTokens, s.CompletionTokens, s.TokensPerSecond))) for i, p := range s.PerProvider { @@ -1042,10 +1153,10 @@ func (m Model) statsModal() string { put(faint(fmt.Sprintf(" %-*s %d× %d tok %.1f t/s", nameW, padRaw(p.Provider, nameW), p.CompletedCount, p.PromptTokens+p.CompletionTokens, p.TokensPerSecond))) } - b.WriteString("\n") + blank() // tools - b.WriteString(section("Tools") + "\n") + out = append(out, section("Tools")) put(line(fmt.Sprintf(" %d calls · %d ms", s.ToolCount, s.ToolMs))) for i, tl := range s.PerTool { if i >= statsBreakdownRows { @@ -1054,35 +1165,34 @@ func (m Model) statsModal() string { } put(faint(fmt.Sprintf(" %-*s %d× %d ms", nameW, padRaw(tl.ToolName, nameW), tl.CompletedCount, tl.TotalDurationMs))) } - b.WriteString("\n") + blank() // approvals - b.WriteString(section("Approvals") + "\n") + out = append(out, section("Approvals")) put(line(fmt.Sprintf(" %d req · %d res · %d pending · avg %d ms", s.ApprovalsRequested, s.ApprovalsResolved, s.ApprovalsPending, s.AvgApprovalWaitMs))) for _, tr := range s.PerTier { put(faint(fmt.Sprintf(" %-3s req %d res %d avg %d ms", tr.Tier, tr.RequestedCount, tr.ResolvedCount, tr.AvgWaitMs))) } - b.WriteString("\n") + blank() // failures f := s.Failures - b.WriteString(section("Failures") + "\n") + out = append(out, section("Failures")) put(line(fmt.Sprintf(" inf %d · t/o %d · tool %d · rej %d · stg %d · wf %d", f.InferenceFailures, f.InferenceTimeouts, f.ToolFailures, f.ToolRejections, f.StageFailures, f.WorkflowFailures))) - b.WriteString("\n") + blank() // time accounting other := 100.0 - s.InferencePct - s.ToolPct - s.ApprovalWaitPct if other < 0 { other = 0 } - b.WriteString(section("Time") + "\n") + out = append(out, section("Time")) put(line(fmt.Sprintf(" inf %.1f%% · tools %.1f%% · wait %.1f%% · other %.1f%%", s.InferencePct, s.ToolPct, s.ApprovalWaitPct, other))) - b.WriteString("\n" + modalHints(t, [][2]string{{"S/esc", "close"}})) - return t.Overlay.Width(w).Render(b.String()) + return out } // shortID trims a long artifact id for the list column while staying identifiable. diff --git a/apps/tui-go/internal/app/update.go b/apps/tui-go/internal/app/update.go index 14946733..04da8531 100644 --- a/apps/tui-go/internal/app/update.go +++ b/apps/tui-go/internal/app/update.go @@ -270,6 +270,7 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { m.enterInsert(ModeRouter) case "?": m.overlay = OverlayHelp + m.modalScroll = 0 case "/": m.enterInsert(ModeFilter) case "j": @@ -687,8 +688,29 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { } } case OverlayHelp: - // Any key dismisses the cheat-sheet (esc handled above). - m.overlay = OverlayNone + // The cheat-sheet scrolls when it's taller than the screen; any non-scroll key + // dismisses it (esc handled above), keeping the quick-glance feel. + page := m.modalBodyRows() + switch { + case k.Type == tea.KeyUp || runeIs(k, "k"): + m.scrollModal(-1) + case k.Type == tea.KeyDown || runeIs(k, "j"): + m.scrollModal(1) + case k.Type == tea.KeyPgUp: + m.scrollModal(-page) + case k.Type == tea.KeyPgDown: + m.scrollModal(page) + case k.Type == tea.KeyCtrlU: + m.scrollModal(-page / 2) + case k.Type == tea.KeyCtrlD: + m.scrollModal(page / 2) + case runeIs(k, "g"): + m.modalScroll = 0 + case runeIs(k, "G"): + m.modalScroll = m.scrollableModalMax(len(m.activeModalBody())) + default: + m.overlay = OverlayNone + } case OverlayToolPalette: if runeIs(k, "t") { m.overlay = OverlayNone @@ -706,14 +728,17 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { m.artifactScroll = 0 } case k.Type == tea.KeyPgUp: - if m.artifactScroll > 0 { - m.artifactScroll-- - } + m.scrollArtifact(-m.artifactContentBodyH()) case k.Type == tea.KeyPgDown: - contentW := m.modalWidth() - 6 - if m.artifactScroll < m.artifactContentMaxScroll(contentW) { - m.artifactScroll++ - } + m.scrollArtifact(m.artifactContentBodyH()) + case k.Type == tea.KeyCtrlU: + m.scrollArtifact(-m.artifactContentBodyH() / 2) + case k.Type == tea.KeyCtrlD: + m.scrollArtifact(m.artifactContentBodyH() / 2) + case runeIs(k, "g"): + m.artifactScroll = 0 + case runeIs(k, "G"): + m.artifactScroll = m.artifactContentMaxScroll(m.modalWidth() - 6) case runeIs(k, "v"): m.overlay = OverlayNone } @@ -739,7 +764,25 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { m.overlay = OverlayNone } case OverlayStats: - if runeIs(k, "S") { + page := m.modalBodyRows() + switch { + case k.Type == tea.KeyUp || runeIs(k, "k"): + m.scrollModal(-1) + case k.Type == tea.KeyDown || runeIs(k, "j"): + m.scrollModal(1) + case k.Type == tea.KeyPgUp: + m.scrollModal(-page) + case k.Type == tea.KeyPgDown: + m.scrollModal(page) + case k.Type == tea.KeyCtrlU: + m.scrollModal(-page / 2) + case k.Type == tea.KeyCtrlD: + m.scrollModal(page / 2) + case runeIs(k, "g"): + m.modalScroll = 0 + case runeIs(k, "G"): + m.modalScroll = m.scrollableModalMax(len(m.activeModalBody())) + case runeIs(k, "S"): m.overlay = OverlayNone } case OverlayIdeas: @@ -962,6 +1005,7 @@ func (m *Model) openStats() { return } m.overlay = OverlayStats + m.modalScroll = 0 // Reuse a cached report only if it's for this session; otherwise show a loading state. if m.statsFor != m.selectedID { m.stats = nil @@ -1082,6 +1126,7 @@ func (m Model) execPalette(id string) (tea.Model, tea.Cmd) { m.toggleInlineActions() case "help": m.overlay = OverlayHelp + m.modalScroll = 0 case "mode": m.cycleChatMode() case "cancel": From 0139225cc4505a152369c4a45d96bbe36c729eb0 Mon Sep 17 00:00:00 2001 From: kami Date: Mon, 22 Jun 2026 09:42:58 +0000 Subject: [PATCH 056/107] chore: drop stale .gitignore entry for the removed Kotlin TUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apps/tui (the pre-Go-rewrite Kotlin TUI) is already gone — not on disk, not tracked, and not in settings.gradle — so its runtime-logs ignore rule pointed at a path that no longer exists. The apps/tui mentions in docs/epics/epic-13* are left as historical record. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 3 --- 1 file changed, 3 deletions(-) diff --git a/.gitignore b/.gitignore index b6b36370..a9896119 100644 --- a/.gitignore +++ b/.gitignore @@ -76,9 +76,6 @@ current.log # Go TUI build output apps/tui-go/bin/ -# TUI runtime logs (Kotlin TUI, pre-Go rewrite) -apps/tui/logs/ - # Server runtime logs apps/server/logs/ From a547bc4342464d8c19c4888ddbdac44a54942038 Mon Sep 17 00:00:00 2001 From: kami Date: Mon, 22 Jun 2026 09:47:15 +0000 Subject: [PATCH 057/107] chore: remove dead apps/worker and apps/desktop stub modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both were scaffolding placeholders from Epic 14 (2c459da) that were never built out: each was just an `application` build.gradle (clikt only, no internal deps) and a one-line `println` Main.kt. Nothing depended on them — no project(...) reference anywhere, only the settings.gradle includes, and no script/doc/CI launched their MainKt — so they only cost build time configuring two inert modules. Delete both module dirs and drop their include lines from settings.gradle. `./gradlew projects` now lists only :apps:cli and :apps:server (configures clean). Co-Authored-By: Claude Opus 4.8 --- apps/desktop/build.gradle | 14 -------------- .../main/kotlin/com/correx/apps/desktop/Main.kt | 5 ----- apps/worker/build.gradle | 14 -------------- .../src/main/kotlin/com/correx/apps/worker/Main.kt | 5 ----- settings.gradle | 2 -- 5 files changed, 40 deletions(-) delete mode 100644 apps/desktop/build.gradle delete mode 100644 apps/desktop/src/main/kotlin/com/correx/apps/desktop/Main.kt delete mode 100644 apps/worker/build.gradle delete mode 100644 apps/worker/src/main/kotlin/com/correx/apps/worker/Main.kt diff --git a/apps/desktop/build.gradle b/apps/desktop/build.gradle deleted file mode 100644 index 2413f3cd..00000000 --- a/apps/desktop/build.gradle +++ /dev/null @@ -1,14 +0,0 @@ -plugins { - id 'org.jetbrains.kotlin.jvm' - id 'application' -} - -application { - mainClass = 'com.correx.apps.desktop.MainKt' -} - -dependencies { - implementation "com.github.ajalt.clikt:clikt:5.0.1" -} - -tasks.named("koverVerify").configure { enabled = false } diff --git a/apps/desktop/src/main/kotlin/com/correx/apps/desktop/Main.kt b/apps/desktop/src/main/kotlin/com/correx/apps/desktop/Main.kt deleted file mode 100644 index fe456df0..00000000 --- a/apps/desktop/src/main/kotlin/com/correx/apps/desktop/Main.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.correx.apps.desktop - -fun main() { - println("correx :: apps/desktop") -} diff --git a/apps/worker/build.gradle b/apps/worker/build.gradle deleted file mode 100644 index 9def4d38..00000000 --- a/apps/worker/build.gradle +++ /dev/null @@ -1,14 +0,0 @@ -plugins { - id 'org.jetbrains.kotlin.jvm' - id 'application' -} - -application { - mainClass = 'com.correx.apps.worker.MainKt' -} - -dependencies { - implementation "com.github.ajalt.clikt:clikt:5.0.1" -} - -tasks.named("koverVerify").configure { enabled = false } diff --git a/apps/worker/src/main/kotlin/com/correx/apps/worker/Main.kt b/apps/worker/src/main/kotlin/com/correx/apps/worker/Main.kt deleted file mode 100644 index 55fa81ad..00000000 --- a/apps/worker/src/main/kotlin/com/correx/apps/worker/Main.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.correx.apps.worker - -fun main() { - println("correx :: apps/worker") -} diff --git a/settings.gradle b/settings.gradle index b98ee139..6fabc8fd 100644 --- a/settings.gradle +++ b/settings.gradle @@ -9,8 +9,6 @@ rootProject.name = 'correx' include ':apps:cli' include ':apps:server' -include ':apps:worker' -include ':apps:desktop' include ':core:kernel' include ':core:events' From 85121cec9de86a24ca0fcac2d68f01a5eddb099f Mon Sep 17 00:00:00 2001 From: kami Date: Mon, 22 Jun 2026 09:52:37 +0000 Subject: [PATCH 058/107] fix(tui-go): event-stream badge tracks session status, not just the socket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The EVENTS panel showed "● live" off m.connected alone, so a completed/failed/ paused session kept reading "live" as long as the websocket was up — stale, since a terminal session emits no more events. Derive the badge from the selected session's Status (the same vocabulary the status bar uses): ✓ done, ✗ failed, ‖ paused, ● live while active, ○ idle when disconnected. event_badge_test.go covers all five. Co-Authored-By: Claude Opus 4.8 --- apps/tui-go/internal/app/event_badge_test.go | 33 ++++++++++++++++++++ apps/tui-go/internal/app/view.go | 31 ++++++++++++++---- 2 files changed, 58 insertions(+), 6 deletions(-) create mode 100644 apps/tui-go/internal/app/event_badge_test.go diff --git a/apps/tui-go/internal/app/event_badge_test.go b/apps/tui-go/internal/app/event_badge_test.go new file mode 100644 index 00000000..081f2cd1 --- /dev/null +++ b/apps/tui-go/internal/app/event_badge_test.go @@ -0,0 +1,33 @@ +package app + +import ( + "strings" + "testing" +) + +// The event-stream badge must track the selected session's lifecycle, not just the socket: +// a terminal/paused session emits no more events, so it must not keep reading "● live". +func TestEventStreamBadgeReflectsSessionStatus(t *testing.T) { + cases := []struct{ status, want string }{ + {"ACTIVE", "● live"}, + {"COMPLETED", "✓ done"}, + {"FAILED", "✗ failed"}, + {"PAUSED awaiting approval", "‖ paused"}, + } + for _, c := range cases { + m := inSessionModel(120, 40) + m.connected = true + m.sessions[0].Status = c.status + if got := m.eventStreamBadge(); !strings.Contains(got, c.want) { + t.Errorf("status %q: badge = %q, want substring %q", c.status, got, c.want) + } + } + + // Disconnected always reads idle, regardless of the session's status. + m := inSessionModel(120, 40) + m.connected = false + m.sessions[0].Status = "ACTIVE" + if got := m.eventStreamBadge(); !strings.Contains(got, "○ idle") { + t.Errorf("disconnected: badge = %q, want idle", got) + } +} diff --git a/apps/tui-go/internal/app/view.go b/apps/tui-go/internal/app/view.go index 0aae1cd8..3f3d37e5 100644 --- a/apps/tui-go/internal/app/view.go +++ b/apps/tui-go/internal/app/view.go @@ -590,12 +590,7 @@ func (m Model) buildTranscriptRows(w int) ([]string, []int) { func (m Model) eventRows(w, h int) []string { t := m.theme header := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render("event stream") + - t.span(" ", t.P.Bg) - if m.connected { - header += lipgloss.NewStyle().Foreground(t.P.OK).Background(t.P.Bg).Render("● live") - } else { - header += t.span("○ idle", t.P.Faint) - } + t.span(" ", t.P.Bg) + m.eventStreamBadge() s := m.session(m.selectedID) if s == nil || len(s.Events) == 0 { @@ -617,6 +612,30 @@ func (m Model) eventRows(w, h int) []string { return append([]string{header, ""}, evRows...) } +// eventStreamBadge reflects whether the *selected session's* stream is still live, not +// just whether the socket is connected: a completed / failed / paused session emits no +// more events, so the old connection-only "● live" went stale the moment a session ended. +func (m Model) eventStreamBadge() string { + t := m.theme + if !m.connected { + return t.span("○ idle", t.P.Faint) + } + if s := m.session(m.selectedID); s != nil { + badge := func(fg lipgloss.Color, text string) string { + return lipgloss.NewStyle().Foreground(fg).Background(t.P.Bg).Render(text) + } + switch u := strings.ToUpper(s.Status); { + case strings.Contains(u, "FAIL"): + return badge(t.P.Bad, "✗ failed") + case strings.Contains(u, "COMPLETE"): + return badge(t.P.OK, "✓ done") + case strings.Contains(u, "PAUSE"): + return badge(t.P.Warn, "‖ paused") + } + } + return lipgloss.NewStyle().Foreground(t.P.OK).Background(t.P.Bg).Render("● live") +} + // --- width helpers --- // shortPath abbreviates the user's home dir to ~ for a compact cwd in the status bar. From 454f24fc3a65a591b9c7defaea114a3396827310 Mon Sep 17 00:00:00 2001 From: kami Date: Mon, 22 Jun 2026 10:04:41 +0000 Subject: [PATCH 059/107] feat(tui-go): show last-activity date in the starting-view session list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The idle "sessions" panel listed id/status/name/stage but no time at all. Add a right-aligned last-activity timestamp (Session.LastEventAt) per row — "Jan 02 15:04" within the current year, "Jan 02 2006" for older — so old sessions are distinguishable at a glance. The name is truncated first so the box's width clamp never eats the date column. (The R resume overlay already shows a relative age; this is the live list on the starting screen.) Co-Authored-By: Claude Opus 4.8 --- apps/tui-go/internal/app/view.go | 39 ++++++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/apps/tui-go/internal/app/view.go b/apps/tui-go/internal/app/view.go index 3f3d37e5..b42abb72 100644 --- a/apps/tui-go/internal/app/view.go +++ b/apps/tui-go/internal/app/view.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "strings" + "time" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/ansi" @@ -431,16 +432,46 @@ func (m Model) sessionRows(w int) []string { idCell := t.span("["+short+"] ", t.P.Faint) statusCell := lipgloss.NewStyle().Foreground(statusColor(t, s.Status)).Background(t.P.Bg).Bold(true). Render(padRaw(statusLabel(s.Status), 9)) - nameCell := lipgloss.NewStyle().Foreground(nameFg).Background(t.P.Bg).Render(" " + s.Name) - stage := "" + stageStr := "" if s.CurrentStage != "" { - stage = t.span(" ["+s.CurrentStage+"]", t.P.Dim) + stageStr = " [" + s.CurrentStage + "]" } - rows = append(rows, bar+idCell+statusCell+nameCell+stage) + dateCell := t.span(shortDateTime(s.LastEventAt), t.P.Faint) + + // Right-align the last-activity date; truncate the name first so the date column + // always survives the box's width clamp (padTo) even on a slim panel. + fixed := lipgloss.Width(bar) + lipgloss.Width(idCell) + lipgloss.Width(statusCell) + 1 + len([]rune(stageStr)) + name := s.Name + if budget := w - fixed - lipgloss.Width(dateCell) - 1; budget >= 1 && len([]rune(name)) > budget { + name = truncate(name, budget) + } + nameCell := lipgloss.NewStyle().Foreground(nameFg).Background(t.P.Bg).Render(" " + name) + stageCell := t.span(stageStr, t.P.Dim) + + left := bar + idCell + statusCell + nameCell + stageCell + gap := w - lipgloss.Width(left) - lipgloss.Width(dateCell) + if gap < 1 { + gap = 1 + } + rows = append(rows, left+t.span(strings.Repeat(" ", gap), t.P.Bg)+dateCell) } return rows } +// shortDateTime formats a millis-since-epoch instant as a compact local timestamp for the +// session list: "Jan 02 15:04" within the current year, "Jan 02 2006" for older years. +// Empty for a zero/absent time (renders as no date rather than a bogus epoch). +func shortDateTime(ms int64) string { + if ms <= 0 { + return "" + } + ts := time.UnixMilli(ms) + if ts.Year() != time.Now().Year() { + return ts.Format("Jan 02 2006") + } + return ts.Format("Jan 02 15:04") +} + func (m Model) workflowRows(w int) []string { t := m.theme if len(m.workflows) == 0 { From 035f7df7044f750b1af2ed7df37a5a00ec5e1e27 Mon Sep 17 00:00:00 2001 From: kami Date: Mon, 22 Jun 2026 10:04:41 +0000 Subject: [PATCH 060/107] feat(tui-go): make the ? overlay list every keybind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audited handleNormalKey / handleApprovalKey / the OverlayDiff handler against the help cheat-sheet and found gaps: enter, ↑↓/jk, /, s, c, a, w, q and ? itself were bound but undocumented. Reorganise helpBody into navigate / session / overlays / approval band / diff-viewer groups covering all of them. help_complete_test.go asserts a row exists for every binding so the help can't silently drift from the handlers. The longer sheet scrolls fine via the modal-scroll support. Co-Authored-By: Claude Opus 4.8 --- .../tui-go/internal/app/help_complete_test.go | 60 +++++++++++++++++++ apps/tui-go/internal/app/overlays.go | 24 ++++++-- 2 files changed, 78 insertions(+), 6 deletions(-) create mode 100644 apps/tui-go/internal/app/help_complete_test.go diff --git a/apps/tui-go/internal/app/help_complete_test.go b/apps/tui-go/internal/app/help_complete_test.go new file mode 100644 index 00000000..38458693 --- /dev/null +++ b/apps/tui-go/internal/app/help_complete_test.go @@ -0,0 +1,60 @@ +package app + +import ( + "strings" + "testing" +) + +// Every key the TUI binds (normal mode, approval band, and the diff/preview viewer) must +// have a row in the ? overlay. Keyed by a distinctive description phrase so deleting a row +// fails loudly here — keep this list in step with handleNormalKey / handleApprovalKey and +// the OverlayDiff handler whenever a binding is added or changed. +func TestHelpCoversEveryKeybind(t *testing.T) { + m := inSessionModel(120, 80) + help := strings.Join(m.helpBody(), "\n") + + want := []string{ + // navigate + "move the session / list selection", // ↑↓ / j k + "open the selected session", // enter + "filter the session list", // / + "jump to your previous / next message", // ^↑ / ^↓ + "scroll output", // PgUp / PgDn (+ ^u / ^d) + "drop selection", // esc + "back to the session list", // l + // session + "compose a message", // i + "toggle chat / steering mode", // s + "show workflows", // w + "copy the selected message", // y + "open the latest diff / preview", // ^x + "cancel the running session", // c + "re-open a dismissed approval gate", // a + "quit", // q + // overlays + "command palette", // p + "this keybindings help", // ? + "event inspector", // e + "tools / artifacts", // t / v + "resume past sessions", // S / R + "swap model / edit config", // m / g + "grants / idea board", // G / I + // approval band + "approve once", // y / a / enter + "reject", // n / r + "steer (add a note)", // e / s + "approve-always", // A + "walk the pending queue", // ↑ / ↓ + "fullscreen the diff / command preview", // ^x + "dismiss for now", // esc + // diff / preview viewer + "scroll a line", // ↑ / ↓ + "scroll a page", // PgUp / PgDn + "jump to top / end", // g / G + } + for _, w := range want { + if !strings.Contains(help, w) { + t.Errorf("? help is missing a row for %q", w) + } + } +} diff --git a/apps/tui-go/internal/app/overlays.go b/apps/tui-go/internal/app/overlays.go index 503c616b..5d3787d4 100644 --- a/apps/tui-go/internal/app/overlays.go +++ b/apps/tui-go/internal/app/overlays.go @@ -818,20 +818,31 @@ func (m Model) helpBody() []string { } out = append(out, "") } - section("session", []kb{ - {"i", "compose a message"}, + section("navigate", []kb{ + {"↑↓ / j k", "move the session / list selection"}, + {"enter", "open the selected session"}, + {"/", "filter the session list"}, {"^↑ / ^↓", "jump to your previous / next message"}, - {"y", "copy the selected message (OSC52)"}, {"PgUp / PgDn", "scroll output (^u / ^d half-page)"}, {"esc", "drop selection / scroll → follow newest"}, - {"^x", "open the latest diff"}, {"l", "back to the session list"}, }) + section("session", []kb{ + {"i", "compose a message"}, + {"s", "toggle chat / steering mode"}, + {"w", "show workflows (idle screen)"}, + {"y", "copy the selected message (OSC52)"}, + {"^x", "open the latest diff / preview"}, + {"c", "cancel the running session"}, + {"a", "re-open a dismissed approval gate"}, + {"q", "quit"}, + }) section("overlays", []kb{ - {"p", "command palette (shows all shortcuts)"}, + {"p", "command palette (all shortcuts)"}, + {"?", "this keybindings help"}, {"e", "event inspector (/ to filter)"}, {"t / v", "tools / artifacts"}, - {"S / R", "session stats / resume sessions"}, + {"S / R", "session stats / resume past sessions"}, {"m / g", "swap model / edit config"}, {"G / I", "grants / idea board"}, }) @@ -842,6 +853,7 @@ func (m Model) helpBody() []string { {"A", "approve-always — choose scope"}, {"↑ / ↓", "walk the pending queue"}, {"^x", "fullscreen the diff / command preview"}, + {"esc", "dismiss for now (a re-opens)"}, }) section("diff / preview viewer (^x)", []kb{ {"↑ / ↓", "scroll a line"}, From 4e4d7c186a6d77048a47da2ea6e52f3ac1fafdbd Mon Sep 17 00:00:00 2001 From: kami Date: Mon, 22 Jun 2026 10:09:56 +0000 Subject: [PATCH 061/107] fix(tui-go): make the write/edit output-panel number meaningful MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The collapsed tool row showed "tool output (N chars)" where N was len() of the raw unified diff — i.e. the diff blob's *byte* length (headers, @@, +/-/context all counted), labelled "chars". That number corresponded to nothing the operator cares about, and len() is bytes not characters. - Summarise a write/edit by the diff's row count instead — "· diff (7 rows) — ^x to view" — matching exactly what the ^x modal reports. Non-diff output (the defensive fallback) now counts runes, not bytes, so "N chars" is truthful. Retire itoaLen (the byte-count-as-"len" footgun). - Harden the (+a −b) line counts in diffSummary: guard the file header with the trailing space ("+++ "/"--- "), so a changed line whose content starts with "++"/"--" is counted instead of mistaken for a header. Apply the same guard in parseUnifiedDiff so such a line is rendered (and counted) rather than dropped. tool_summary_test.go covers the diff-row summary, the rune (not byte) count, and the ++/-- content-line edge case. Co-Authored-By: Claude Opus 4.8 --- apps/tui-go/internal/app/diff.go | 5 +- apps/tui-go/internal/app/server.go | 4 +- apps/tui-go/internal/app/tool_summary_test.go | 47 +++++++++++++++++++ apps/tui-go/internal/app/view.go | 14 +++++- 4 files changed, 65 insertions(+), 5 deletions(-) create mode 100644 apps/tui-go/internal/app/tool_summary_test.go diff --git a/apps/tui-go/internal/app/diff.go b/apps/tui-go/internal/app/diff.go index f8f5f707..bf77ec35 100644 --- a/apps/tui-go/internal/app/diff.go +++ b/apps/tui-go/internal/app/diff.go @@ -53,7 +53,10 @@ func parseUnifiedDiff(diff string) []diffRow { } for _, ln := range strings.Split(strings.TrimRight(diff, "\n"), "\n") { switch { - case strings.HasPrefix(ln, "+++"), strings.HasPrefix(ln, "---"), + // File headers carry a trailing space ("+++ b/x"); requiring it means a real + // changed line whose content starts with "++"/"--" isn't mistaken for a header + // and silently dropped from the rendered diff (and its row count). + case strings.HasPrefix(ln, "+++ "), strings.HasPrefix(ln, "--- "), strings.HasPrefix(ln, "diff "), strings.HasPrefix(ln, "index "): continue case strings.HasPrefix(ln, "@@"): diff --git a/apps/tui-go/internal/app/server.go b/apps/tui-go/internal/app/server.go index 6b5c0dc2..7be87790 100644 --- a/apps/tui-go/internal/app/server.go +++ b/apps/tui-go/internal/app/server.go @@ -425,9 +425,9 @@ func diffSummary(diff string) (path string, added, removed int) { if p != "" && p != "/dev/null" { path = p } - case strings.HasPrefix(ln, "+") && !strings.HasPrefix(ln, "+++"): + case strings.HasPrefix(ln, "+") && !strings.HasPrefix(ln, "+++ "): added++ - case strings.HasPrefix(ln, "-") && !strings.HasPrefix(ln, "---"): + case strings.HasPrefix(ln, "-") && !strings.HasPrefix(ln, "--- "): removed++ } } diff --git a/apps/tui-go/internal/app/tool_summary_test.go b/apps/tui-go/internal/app/tool_summary_test.go new file mode 100644 index 00000000..737afa9b --- /dev/null +++ b/apps/tui-go/internal/app/tool_summary_test.go @@ -0,0 +1,47 @@ +package app + +import ( + "strings" + "testing" +) + +// The collapsed tool row for a write/edit must summarise the diff by its row count (what +// ^x shows), not the raw diff byte length that used to read as a meaningless "N chars". +func TestToolRowSummary_DiffReportsRows(t *testing.T) { + diff := "--- a/f\n+++ b/f\n@@ -1,2 +1,3 @@\n context\n-old line\n+new line\n+added line\n" + got := toolRowSummary(diff) + wantRows := previewRowCount(diff) + if !strings.Contains(got, "diff (") || !strings.Contains(got, itoa(wantRows)+" rows") { + t.Fatalf("diff summary = %q, want a diff row count of %d", got, wantRows) + } + if strings.Contains(got, "tool output") { + t.Errorf("a diff should not be labelled 'tool output': %q", got) + } +} + +// Non-diff output reports a true character (rune) count, not a byte count. +func TestToolRowSummary_NonDiffCountsRunes(t *testing.T) { + content := "café — déjà" // 11 runes, but 14 bytes (é, —, à are multi-byte) + got := toolRowSummary(content) + if !strings.Contains(got, "(11 chars)") { + t.Fatalf("non-diff summary = %q, want 11 chars (runes, not bytes)", got) + } +} + +// A changed line whose content starts with "++"/"--" must still be counted (and rendered): +// requiring the trailing space on the file-header guard prevents mistaking it for a header. +func TestDiffSummary_CountsDoublePrefixContentLines(t *testing.T) { + // The added line's content is "++flag"; in the diff that's "+" + "++flag" = "+++flag". + diff := "--- a/f\n+++ b/f\n@@ -0,0 +1,2 @@\n+++flag\n+normal\n" + _, added, removed := diffSummary(diff) + if added != 2 { + t.Errorf("added = %d, want 2 (the '+++flag' content line must count)", added) + } + if removed != 0 { + t.Errorf("removed = %d, want 0", removed) + } + // The renderer must keep the line too (2 add rows), not drop it as a header. + if n := previewRowCount(diff); n != 2 { + t.Errorf("rendered rows = %d, want 2", n) + } +} diff --git a/apps/tui-go/internal/app/view.go b/apps/tui-go/internal/app/view.go index b42abb72..2b212ccc 100644 --- a/apps/tui-go/internal/app/view.go +++ b/apps/tui-go/internal/app/view.go @@ -589,7 +589,7 @@ func (m Model) buildTranscriptRows(w int) ([]string, []int) { rows = append(rows, t.span(s, t.P.Faint)) } case "tool": - rows = append(rows, t.span("· tool output ("+itoaLen(e.Content)+" chars) — ^x to view", t.P.Dim)) + rows = append(rows, t.span(toolRowSummary(e.Content), t.P.Dim)) case "narration_llm": diamond := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render("◆") lines := wrap(e.Content, w-2) @@ -856,7 +856,17 @@ func itoa(n int) string { return string(b[i:]) } -func itoaLen(s string) string { return itoa(len(s)) } +// toolRowSummary collapses a tool-output transcript entry into a one-line pointer. A +// write/edit entry holds a unified diff, so summarise it by what ^x actually shows — the +// diff's row count — instead of the raw diff byte length (which read as a meaningless +// "N chars": it counted +/-/@@/header bytes, not anything the operator cares about, and +// len() is bytes not characters). Non-diff output falls back to a true character count. +func toolRowSummary(content string) string { + if isUnifiedDiff(content) { + return "· diff (" + itoa(previewRowCount(content)) + " rows) — ^x to view" + } + return "· tool output (" + itoa(len([]rune(content))) + " chars) — ^x to view" +} // metricsSuffix formats the faint latency+token annotation for a ROUTER turn. func metricsSuffix(mtr *TurnMetrics) string { From 6bee3a7d31fe4d7adb78bb236cf144e2d669f594 Mon Sep 17 00:00:00 2001 From: kami Date: Mon, 22 Jun 2026 10:14:51 +0000 Subject: [PATCH 062/107] fix(tui-go): render event/activity times in local time, not UTC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit formatTime forced .UTC(), so every event-log timestamp and the welcome "recent" times read hours off the operator's wall clock — and inconsistent with the local date just added to the session list. time.UnixMilli is already local, so the explicit .UTC() was the bug; drop it. No test asserts a clock string (the render matrix only checks event type/detail), so this is display-only. Co-Authored-By: Claude Opus 4.8 --- apps/tui-go/internal/app/render_matrix_test.go | 6 +++--- apps/tui-go/internal/app/server.go | 5 ++++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/tui-go/internal/app/render_matrix_test.go b/apps/tui-go/internal/app/render_matrix_test.go index 5b17ed7d..9bb00896 100644 --- a/apps/tui-go/internal/app/render_matrix_test.go +++ b/apps/tui-go/internal/app/render_matrix_test.go @@ -45,9 +45,9 @@ const ( surfaceStatusBar // top status bar (renderStatus) ) -// fixedNow is a deterministic timestamp used for every event carrying OccurredAt, so -// formatTime output is stable across runs/machines. (Events that fall back to -// nowMillis() are asserted only on their type/detail text, never the clock.) +// fixedNow is a deterministic timestamp used for every event carrying OccurredAt. The +// matrix asserts only event type/detail text, never the rendered clock — formatTime is +// local (machine-TZ dependent), so the time string is intentionally not part of any golden. const fixedNow int64 = 1_700_000_000_000 // matrixCase is one event/entry kind in the matrix. diff --git a/apps/tui-go/internal/app/server.go b/apps/tui-go/internal/app/server.go index 7be87790..7d76d259 100644 --- a/apps/tui-go/internal/app/server.go +++ b/apps/tui-go/internal/app/server.go @@ -22,8 +22,11 @@ func containsFold(haystack, needle string) bool { return strings.Contains(strings.ToLower(haystack), strings.ToLower(needle)) } +// formatTime renders an event/activity instant as a local wall-clock time, matching the +// operator's clock (and the local date shown in the session list). It was previously forced +// to UTC, so every event timestamp read hours off the user's actual time. func formatTime(epochMillis int64) string { - return time.UnixMilli(epochMillis).UTC().Format("15:04:05") + return time.UnixMilli(epochMillis).Format("15:04:05") } // inferCategory maps an event type string to a display category, matching the From 81280c5bd5ff6f5993059a81e24cad80a5aae9df Mon Sep 17 00:00:00 2001 From: kami Date: Mon, 22 Jun 2026 10:28:28 +0000 Subject: [PATCH 063/107] feat(tui-go): welcome "recent" list uses the local date, matching the session list Was the UTC-then-local time-of-day; now the same "Jan 02 15:04" / "Jan 02 2006" shortDateTime as the session list, so a previous-day session isn't shown as if it were today. Co-Authored-By: Claude Opus 4.8 --- apps/tui-go/internal/app/view.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/tui-go/internal/app/view.go b/apps/tui-go/internal/app/view.go index 2b212ccc..26bd5c77 100644 --- a/apps/tui-go/internal/app/view.go +++ b/apps/tui-go/internal/app/view.go @@ -516,7 +516,7 @@ func (m Model) welcomeRows(w int) []string { } for _, s := range m.sessions[start:] { mark := statusGlyph(t, s.Status) - rows = append(rows, t.span(" "+s.Name+" ", t.P.Fg)+t.span(formatTime(s.LastEventAt), t.P.Faint)+" "+mark) + rows = append(rows, t.span(" "+s.Name+" ", t.P.Fg)+t.span(shortDateTime(s.LastEventAt), t.P.Faint)+" "+mark) } } return rows From 9d0061274210a562c3562c44f044d96833c118d4 Mon Sep 17 00:00:00 2001 From: kami Date: Mon, 22 Jun 2026 11:33:19 +0000 Subject: [PATCH 064/107] =?UTF-8?q?feat(tui-go):=20idle=20launcher=20?= =?UTF-8?q?=E2=80=94=20centered=20input=20+=20status/keys=20rail=20(Phase?= =?UTF-8?q?=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the idle two-panel layout (session list + welcome, where the welcome panel duplicated the list) with an opencode-style launcher: a compact, centered input whose lower-right shows the launch target and model (chat by default), a hideable right rail of status + quick keys, and no session list — it's a keypress away via R. - Tab (or w) cycles the launch target: chat → workflows → chat (launcherWf), shown at the input. Enter on chat starts a chat; on a workflow, StartSession with the typed text as the brief. - Right rail (connection, session/active counts, i/Tab/R/?/p keys) hides via the new "rail" palette command. Drops automatically on narrow terminals. - View() suppresses the bottom input bar on idle (the input is the launcher); footer + ? help updated to the launcher keys (help-coverage test extended). The old idle-panel renderers (sessionRows/welcomeRows/workflowRows) are now unused but kept in the tree pending Phase 2 + a decision on where the session dates should live now that the idle list is gone. Multi-line input (Ctrl+J / Alt+Enter newline) is Phase 2. Co-Authored-By: Claude Opus 4.8 --- .../tui-go/internal/app/help_complete_test.go | 1 + apps/tui-go/internal/app/model.go | 7 +- apps/tui-go/internal/app/overlays.go | 1 + .../tui-go/internal/app/render_matrix_test.go | 6 +- apps/tui-go/internal/app/update.go | 36 +++- apps/tui-go/internal/app/view.go | 159 ++++++++++++++---- 6 files changed, 163 insertions(+), 47 deletions(-) diff --git a/apps/tui-go/internal/app/help_complete_test.go b/apps/tui-go/internal/app/help_complete_test.go index 38458693..4f995079 100644 --- a/apps/tui-go/internal/app/help_complete_test.go +++ b/apps/tui-go/internal/app/help_complete_test.go @@ -17,6 +17,7 @@ func TestHelpCoversEveryKeybind(t *testing.T) { // navigate "move the session / list selection", // ↑↓ / j k "open the selected session", // enter + "cycle launch target", // Tab / w "filter the session list", // / "jump to your previous / next message", // ^↑ / ^↓ "scroll output", // PgUp / PgDn (+ ^u / ^d) diff --git a/apps/tui-go/internal/app/model.go b/apps/tui-go/internal/app/model.go index 2289bef7..ebce1517 100644 --- a/apps/tui-go/internal/app/model.go +++ b/apps/tui-go/internal/app/model.go @@ -227,7 +227,12 @@ type Model struct { wfVisible bool wfPendingID string // workflow chosen, awaiting an intent line before StartSession wfPendingName string - bgUpdates int + // launcher (idle screen): the active "what to launch" selection — 0 = chat, 1..N = + // workflows[i-1] — cycled with Tab and shown at the input's lower-right. railHidden + // folds away the idle status/quick-keys rail. + launcherWf int + railHidden bool + bgUpdates int // input editMode EditMode diff --git a/apps/tui-go/internal/app/overlays.go b/apps/tui-go/internal/app/overlays.go index 5d3787d4..a9655a43 100644 --- a/apps/tui-go/internal/app/overlays.go +++ b/apps/tui-go/internal/app/overlays.go @@ -821,6 +821,7 @@ func (m Model) helpBody() []string { section("navigate", []kb{ {"↑↓ / j k", "move the session / list selection"}, {"enter", "open the selected session"}, + {"Tab / w", "idle: cycle launch target (chat / workflow)"}, {"/", "filter the session list"}, {"^↑ / ^↓", "jump to your previous / next message"}, {"PgUp / PgDn", "scroll output (^u / ^d half-page)"}, diff --git a/apps/tui-go/internal/app/render_matrix_test.go b/apps/tui-go/internal/app/render_matrix_test.go index 9bb00896..2089d57e 100644 --- a/apps/tui-go/internal/app/render_matrix_test.go +++ b/apps/tui-go/internal/app/render_matrix_test.go @@ -494,18 +494,18 @@ func matrixCases() []matrixCase { want: []string{"VRAM 4096/8192M", "GPU 42%"}, }, - // --- workflow list (idle left pane) --- + // --- workflow list → idle launch target (Tab-selected, shown at the input) --- { name: "workflow.list", kinds: []string{protocol.TypeWorkflowList}, surface: surfaceView, - prep: func(m *Model) { m.selectedID = ""; m.sessionEntered = false; m.wfVisible = true; m.wfIndex = 0 }, + prep: func(m *Model) { m.selectedID = ""; m.sessionEntered = false; m.launcherWf = 1 }, build: func() protocol.ServerMessage { return protocol.ServerMessage{Type: protocol.TypeWorkflowList, Workflows: []protocol.WorkflowDto{ {WorkflowID: "healthcheck", Description: "kick the tires"}, }} }, - want: []string{"healthcheck", "kick the tires"}, + want: []string{"healthcheck"}, }, // --- session snapshot (reopen: rebuilds a session from the recent-events tail) --- diff --git a/apps/tui-go/internal/app/update.go b/apps/tui-go/internal/app/update.go index 04da8531..22963c56 100644 --- a/apps/tui-go/internal/app/update.go +++ b/apps/tui-go/internal/app/update.go @@ -206,6 +206,12 @@ func (m Model) handleKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } } + // On the idle launcher, Tab cycles the launch target (chat → workflows → chat) in any + // edit mode, so it works whether or not the input is focused. + if m.displayState() == StateIdle && k.Type == tea.KeyTab { + m.cycleLauncherWf() + return m, nil + } if m.editMode == ModeInsert { return m.handleInsertKey(k) } @@ -300,13 +306,9 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { case "m": m.openModelsOverlay() case "w": + // Launcher: w is an alias for Tab — cycle the launch target (chat / workflow…). if ds == StateIdle { - m.wfVisible = !m.wfVisible - if m.wfVisible { - m.wfIndex = 0 - } else { - m.wfIndex = -1 - } + m.cycleLauncherWf() } case "l": if ds != StateIdle { @@ -1074,6 +1076,7 @@ func paletteCommands() []paletteCmd { {"config", "g", "edit config", "view / change correx settings"}, {"grants", "G", "grants", "view / revoke standing grants"}, {"statusbar", "", "status bar", "show / hide status-bar segments"}, + {"rail", "", "idle rail", "show / hide the idle status + keys rail"}, {"actions", "", "inline actions", "show / hide tool & action rows in output"}, {"help", "?", "help", "keybinding cheat-sheet"}, {"mode", "s", "toggle mode", "switch chat / steering"}, @@ -1122,6 +1125,8 @@ func (m Model) execPalette(id string) (tea.Model, tea.Cmd) { case "statusbar": m.overlay = OverlayStatusbar m.sbIndex = 0 + case "rail": + m.railHidden = !m.railHidden case "actions": m.toggleInlineActions() case "help": @@ -1164,6 +1169,11 @@ func (m *Model) backspace() { m.inputCursor = c - 1 } +// cycleLauncherWf advances the idle launch target: 0 = chat, 1..N = workflows[i-1], wrapping. +func (m *Model) cycleLauncherWf() { + m.launcherWf = (m.launcherWf + 1) % (len(m.workflows) + 1) +} + func (m *Model) cycleChatMode() { if m.chatMode == ChatModeChat { m.chatMode = ChatModeSteering @@ -1211,6 +1221,20 @@ func (m Model) submit() (tea.Model, tea.Cmd) { m.beginIntent(m.workflows[m.wfIndex]) return m, nil } + // Launcher: a workflow is Tab-selected → start it with the typed text as its brief + // (empty brief is allowed, like the intent flow). The server makes the session; we + // focus it when its events arrive (pendingWorkflowFocus). + if m.launcherWf > 0 && m.launcherWf <= len(m.workflows) { + wf := m.workflows[m.launcherWf-1] + m.recordInput(text) + m.client.Send(protocol.StartSession(wf.ID, text)) + m.clearInput() + m.inputMode = ModeRouter + m.editMode = ModeNormal + m.pendingWorkflowFocus = true + m.launcherWf = 0 + return m, nil + } // Entering an already-selected session (blank submit). if text == "" && m.selectedID != "" { m.sessionEntered = true diff --git a/apps/tui-go/internal/app/view.go b/apps/tui-go/internal/app/view.go index 26bd5c77..ca67a7e6 100644 --- a/apps/tui-go/internal/app/view.go +++ b/apps/tui-go/internal/app/view.go @@ -37,11 +37,15 @@ func (m Model) View() string { return m.theme.Screen.Render("correx — terminal too small") } - bottom := m.renderInput() - bottomH := inputH - if m.displayState() == StateApproval { + ds := m.displayState() + bottom, bottomH := m.renderInput(), inputH + switch { + case ds == StateApproval: bottomH = m.approvalBandHeight() bottom = m.renderApprovalBand(bottomH) + case ds == StateIdle: + // Launcher: the input is centered inside the main area, so there's no bottom bar. + bottom, bottomH = "", 0 } mainH := m.height - statusH - footerH - bottomH @@ -49,12 +53,12 @@ func (m Model) View() string { mainH = 3 } - base := lipgloss.JoinVertical(lipgloss.Left, - m.renderStatus(), - m.renderMain(mainH), - bottom, - m.renderFooter(), - ) + sections := []string{m.renderStatus(), m.renderMain(mainH)} + if bottomH > 0 { + sections = append(sections, bottom) + } + sections = append(sections, m.renderFooter()) + base := lipgloss.JoinVertical(lipgloss.Left, sections...) // Stash the base so center() can composite a modal over a dimmed copy of it. m.lastBase = base @@ -244,9 +248,9 @@ func (m Model) renderFooter() string { hints = []string{hint("↑↓", "choose"), hint("enter", "launch"), hint("e", "custom"), hint("esc", "later")} case m.displayState() == StateIdle: if nrw { - hints = []string{hint("i", "name"), hint("/", "filter"), hint("w", "wf"), hint("R", "resume"), hint("p", "cmds"), hint("q", "quit")} + hints = []string{hint("i", "type"), hint("Tab", "wf"), hint("R", "resume"), hint("?", "keys"), hint("p", "cmds"), hint("q", "quit")} } else { - hints = []string{hint("i", "name"), hint("/", "filter"), hint("enter", "open"), hint("jk", "move"), hint("w", "workflows"), hint("R", "resume"), hint("p", "cmds"), hint("q", "quit")} + hints = []string{hint("i", "type"), hint("Tab", "workflow"), hint("R", "resume"), hint("?", "keys"), hint("p", "cmds"), hint("q", "quit")} } default: // StateInSession if nrw { @@ -279,41 +283,122 @@ func (m Model) renderFooter() string { // --- main split --- func (m Model) renderMain(h int) string { + if m.displayState() == StateIdle { + return m.renderLauncher(m.width, h) + } if m.narrow() { return m.renderMainNarrow(h) } leftW := m.width * 63 / 100 rightW := m.width - leftW - var leftTitle, rightTitle string - var leftBody, rightBody []string - leftActive, rightActive := false, false - - switch m.displayState() { - case StateIdle: - leftTitle = "sessions" - if m.wfVisible { - leftTitle = "workflows" - leftBody = m.workflowRows(leftW - 4) - } else { - leftBody = m.sessionRows(leftW - 4) - } - leftActive = true - rightTitle = "welcome" - rightBody = m.welcomeRows(rightW - 4) - case StateInSession, StateApproval: - leftTitle = "output" - leftBody = m.routerRows(leftW-4, h-2) - leftActive = true - rightTitle = "events" - rightBody = m.eventRows(rightW-4, h) - } - - left := m.theme.box(leftTitle, leftBody, leftW, h, leftActive) - right := m.theme.box(rightTitle, rightBody, rightW, h, rightActive) + // In-session / approval: output transcript over the event stream. + left := m.theme.box("output", m.routerRows(leftW-4, h-2), leftW, h, true) + right := m.theme.box("events", m.eventRows(rightW-4, h), rightW, h, false) return lipgloss.JoinHorizontal(lipgloss.Top, left, right) } +// renderLauncher is the idle screen: a compact, opencode-style input centered in the main +// area, with a hideable right rail of status + quick keys. The session list isn't shown — +// it's a keypress away (R). The input's lower-right shows · , Tab-cycled. +func (m Model) renderLauncher(w, h int) string { + t := m.theme + railW := 26 + if m.railHidden || m.narrow() || w < 66 { + railW = 0 + } + leftW := w - railW + + inW := leftW * 82 / 100 + if inW > 84 { + inW = 84 + } + if inW < 24 { + inW = 24 + } + left := lipgloss.Place(leftW, h, lipgloss.Center, lipgloss.Center, m.launcherInput(inW), + lipgloss.WithWhitespaceBackground(t.P.Bg)) + if railW == 0 { + return left + } + rail := lipgloss.Place(railW, h, lipgloss.Left, lipgloss.Top, m.launcherRail(railW), + lipgloss.WithWhitespaceBackground(t.P.Bg)) + return lipgloss.JoinHorizontal(lipgloss.Top, left, rail) +} + +// launcherInput renders the compact input box plus a status sub-line ( · +// left, action hints right). +func (m Model) launcherInput(w int) string { + t := m.theme + insert := m.editMode == ModeInsert + prompt := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render("› ") + caret := t.span(" ", t.P.Bg) + if insert && m.caretVisible() { + caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Render("▏") + } + var line string + switch { + case m.inputBuffer == "" && !insert: + line = prompt + t.span("Ask anything…", t.P.Faint) + case m.inputBuffer == "": + line = prompt + caret + t.span("Ask anything…", t.P.Faint) + default: + line = prompt + t.span(flattenForDisplay(m.inputBuffer), t.P.FgStrong) + caret + } + box := t.box("", []string{line}, w, 3, insert) + + wf := lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.Bg).Render(m.launcherWfName()) + leftSub := " " + wf + t.span(" · ", t.P.Faint) + t.span(m.currentModel, t.P.Faint) + hints := t.span("Tab workflow · enter ↵ ", t.P.Faint) + return box + "\n" + m.justify(leftSub, hints, w, t.P.Bg) +} + +// launcherRail is the idle status + quick-keys panel on the right. +func (m Model) launcherRail(w int) string { + t := m.theme + var rows []string + if m.connected { + rows = append(rows, lipgloss.NewStyle().Foreground(t.P.OK).Background(t.P.Bg).Render("● connected")) + } else { + rows = append(rows, lipgloss.NewStyle().Foreground(t.P.Bad).Background(t.P.Bg).Render("● offline")) + } + count := t.span(plural(len(m.sessions), "session"), t.P.Dim) + if a := m.activeSessionCount(); a > 0 { + count += t.span(" · "+itoa(a)+" active", t.P.Faint) + } + key := func(k, d string) string { + return lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render(padRaw(k, 4)) + t.span(d, t.P.Dim) + } + rows = append(rows, count, "", + key("i", "type"), + key("Tab", "workflow"), + key("R", "resume"), + key("?", "keys"), + key("p", "commands"), + ) + return t.box("correx", rows, w, len(rows)+2, false) +} + +// launcherWfName is the active launch target: "chat" (default) or a workflow id, Tab-cycled. +func (m Model) launcherWfName() string { + if m.launcherWf <= 0 || m.launcherWf > len(m.workflows) { + return "chat" + } + return m.workflows[m.launcherWf-1].ID +} + +// activeSessionCount is the number of sessions not in a terminal (completed/failed) state. +func (m Model) activeSessionCount() int { + n := 0 + for _, s := range m.sessions { + u := strings.ToUpper(s.Status) + if !strings.Contains(u, "COMPLETE") && !strings.Contains(u, "FAIL") { + n++ + } + } + return n +} + // renderMainNarrow gives the main area a single full-width column when the terminal // is too slim for side-by-side panels: the session list when idle, and the output // transcript stacked over the live event strip when in-session (so both still fit From f2be46a743daade684bfe1b007c985904421c40d Mon Sep 17 00:00:00 2001 From: kami Date: Mon, 22 Jun 2026 11:57:41 +0000 Subject: [PATCH 065/107] refactor(tui-go): move session dates to the R overlay; drop dead idle panels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The launcher removed the idle session list, so the per-row date moves to the R resume overlay — now the sole session list. sessionListRow shows an absolute local date (absDateISO → shortDateTime) alongside the relative age: "Jun 22 14:30 · 5m ago", with the year shown for older-than-this-year rows. Delete the now-unused idle-panel renderers (sessionRows / welcomeRows / workflowRows) and renderMainNarrow's dead idle branch (renderLauncher owns idle, narrow included). Add a "resume" preview kind to screenshot the R overlay. Co-Authored-By: Claude Opus 4.8 --- apps/tui-go/internal/app/demo.go | 10 ++ apps/tui-go/internal/app/sessions_overlay.go | 21 +++- apps/tui-go/internal/app/view.go | 109 +------------------ 3 files changed, 30 insertions(+), 110 deletions(-) diff --git a/apps/tui-go/internal/app/demo.go b/apps/tui-go/internal/app/demo.go index 4da069c6..1474bcb9 100644 --- a/apps/tui-go/internal/app/demo.go +++ b/apps/tui-go/internal/app/demo.go @@ -22,6 +22,16 @@ func PreviewFrame(kind string, w, h int) string { m.bgUpdates = 3 m.selectedID = "04a546aa" + case "resume": + m.connected = true + m.currentModel = "llama-cpp:default" + m.overlay = OverlaySessions + m.sessionList = []SessionSummary{ + {SessionID: "04a546aa8b2c", Status: "ACTIVE", WorkflowID: "healthcheck", StageCount: 3, LastActivityAt: "2026-06-22T14:30:05Z"}, + {SessionID: "0d7097bb1f3e", Status: "COMPLETED", WorkflowID: "healthcheck", StageCount: 5, LastActivityAt: "2026-06-21T09:15:00Z"}, + {SessionID: "1dae17cc77aa", Status: "FAILED", WorkflowID: "chat", LastActivityAt: "2025-12-30T18:02:00Z"}, + } + case "workflows": m.connected = true m.currentModel = "llama-cpp:default" diff --git a/apps/tui-go/internal/app/sessions_overlay.go b/apps/tui-go/internal/app/sessions_overlay.go index 6a4f73d1..bb07ece6 100644 --- a/apps/tui-go/internal/app/sessions_overlay.go +++ b/apps/tui-go/internal/app/sessions_overlay.go @@ -228,10 +228,25 @@ func (m Model) sessionListRow(i int) string { } stageCell := mbg(t, padRaw(stage, 22), t.P.Accent2) - age := relativeAge(s.LastActivityAt) - ageCell := mbg(t, age, t.P.Faint) + dateCell := mbg(t, padRaw(absDateISO(s.LastActivityAt), 12), t.P.Dim) + ageCell := mbg(t, "· "+relativeAge(s.LastActivityAt), t.P.Faint) - return marker + idCell + " " + statusCell + " " + stageCell + " " + ageCell + return marker + idCell + " " + statusCell + " " + stageCell + " " + dateCell + " " + ageCell +} + +// absDateISO renders an ISO-8601 instant (the server's LastActivityAt) as a compact local +// date via shortDateTime, or "—" when absent/unparseable. +func absDateISO(iso string) string { + if iso == "" { + return "—" + } + ts, err := time.Parse(time.RFC3339Nano, iso) + if err != nil { + if ts, err = time.Parse(time.RFC3339, iso); err != nil { + return "—" + } + } + return shortDateTime(ts.UnixMilli()) } // shortSessionID trims a long session id to a recognizable prefix for the list. diff --git a/apps/tui-go/internal/app/view.go b/apps/tui-go/internal/app/view.go index ca67a7e6..6446534c 100644 --- a/apps/tui-go/internal/app/view.go +++ b/apps/tui-go/internal/app/view.go @@ -405,15 +405,8 @@ func (m Model) activeSessionCount() int { // — the strip is too valuable to drop, the constraint is width not height). func (m Model) renderMainNarrow(h int) string { w := m.width - if m.displayState() == StateIdle { - title := "sessions" - body := m.sessionRows(w - 4) - if m.wfVisible { - title, body = "workflows", m.workflowRows(w-4) - } - return m.theme.box(title, body, w, h, true) - } - // in-session / approval: stack output over events. + // Idle is handled by renderLauncher (which drops the rail when narrow); this only + // runs for in-session / approval: stack output over events. outH := h * 55 / 100 if outH < 3 { outH = 3 @@ -495,54 +488,6 @@ func (m Model) renderInput() string { // --- body row builders --- -func (m Model) sessionRows(w int) []string { - t := m.theme - list := m.filteredSessions() - if len(list) == 0 { - return []string{t.span("no sessions yet", t.P.Faint), "", t.span("type a name below to begin", t.P.Faint)} - } - rows := make([]string, 0, len(list)) - for _, s := range list { - sel := s.ID == m.selectedID && !m.wfVisible - short := s.ID - if len(short) > 6 { - short = short[:6] - } - nameFg := t.P.Fg - bar := t.span(" ", t.P.Bg) - if sel { - bar = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Render("▌ ") - nameFg = t.P.FgStrong - } - idCell := t.span("["+short+"] ", t.P.Faint) - statusCell := lipgloss.NewStyle().Foreground(statusColor(t, s.Status)).Background(t.P.Bg).Bold(true). - Render(padRaw(statusLabel(s.Status), 9)) - stageStr := "" - if s.CurrentStage != "" { - stageStr = " [" + s.CurrentStage + "]" - } - dateCell := t.span(shortDateTime(s.LastEventAt), t.P.Faint) - - // Right-align the last-activity date; truncate the name first so the date column - // always survives the box's width clamp (padTo) even on a slim panel. - fixed := lipgloss.Width(bar) + lipgloss.Width(idCell) + lipgloss.Width(statusCell) + 1 + len([]rune(stageStr)) - name := s.Name - if budget := w - fixed - lipgloss.Width(dateCell) - 1; budget >= 1 && len([]rune(name)) > budget { - name = truncate(name, budget) - } - nameCell := lipgloss.NewStyle().Foreground(nameFg).Background(t.P.Bg).Render(" " + name) - stageCell := t.span(stageStr, t.P.Dim) - - left := bar + idCell + statusCell + nameCell + stageCell - gap := w - lipgloss.Width(left) - lipgloss.Width(dateCell) - if gap < 1 { - gap = 1 - } - rows = append(rows, left+t.span(strings.Repeat(" ", gap), t.P.Bg)+dateCell) - } - return rows -} - // shortDateTime formats a millis-since-epoch instant as a compact local timestamp for the // session list: "Jan 02 15:04" within the current year, "Jan 02 2006" for older years. // Empty for a zero/absent time (renders as no date rather than a bogus epoch). @@ -557,56 +502,6 @@ func shortDateTime(ms int64) string { return ts.Format("Jan 02 15:04") } -func (m Model) workflowRows(w int) []string { - t := m.theme - if len(m.workflows) == 0 { - return []string{t.span("no workflows advertised", t.P.Faint)} - } - rows := make([]string, 0, len(m.workflows)) - for i, wf := range m.workflows { - marker := t.span(" ", t.P.Bg) - fg := t.P.Fg - if i == m.wfIndex { - marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Render("▸ ") - fg = t.P.FgStrong - } - rows = append(rows, marker+lipgloss.NewStyle().Foreground(fg).Background(t.P.Bg).Render(wf.ID)+t.span(" "+wf.Description, t.P.Faint)) - } - return rows -} - -func (m Model) welcomeRows(w int) []string { - t := m.theme - title := lipgloss.NewStyle().Foreground(t.P.FgStrong).Background(t.P.Bg).Bold(true).Render("Corre") + - lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render("x") + - lipgloss.NewStyle().Foreground(t.P.FgStrong).Background(t.P.Bg).Bold(true).Render(" Agent Harness") - - var status string - if m.connected { - status = lipgloss.NewStyle().Foreground(t.P.OK).Background(t.P.Bg).Render("Connected") + - t.span(" · ", t.P.Faint) + t.span(plural(len(m.sessions), "session"), t.P.Dim) - } else { - status = lipgloss.NewStyle().Foreground(t.P.Bad).Background(t.P.Bg).Render("Disconnected") - } - - rows := []string{title, status, "", - t.span("Select a session from the left", t.P.Dim), - t.span("or type a name to start a new one.", t.P.Dim), ""} - - if n := len(m.sessions); n > 0 { - rows = append(rows, t.span("recent:", t.P.Faint)) - start := 0 - if n > 5 { - start = n - 5 - } - for _, s := range m.sessions[start:] { - mark := statusGlyph(t, s.Status) - rows = append(rows, t.span(" "+s.Name+" ", t.P.Fg)+t.span(shortDateTime(s.LastEventAt), t.P.Faint)+" "+mark) - } - } - return rows -} - func (m Model) routerRows(w, h int) []string { t := m.theme if len(m.routerMessages[m.selectedID]) == 0 { From cde0c330311423d2755922bc98bf40102eaeb681 Mon Sep 17 00:00:00 2001 From: kami Date: Mon, 22 Jun 2026 12:03:06 +0000 Subject: [PATCH 066/107] =?UTF-8?q?feat(tui-go):=20multi-line=20input=20?= =?UTF-8?q?=E2=80=94=20Ctrl+J=20/=20Alt+Enter=20newline,=20growing=20box?= =?UTF-8?q?=20(Phase=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enter still submits; Ctrl+J (always) and Alt+Enter (most terminals) insert a newline at the cursor. True Shift+Enter isn't distinguishable from Enter in bubbletea v1.3.10 (terminals send the same byte, no kitty-protocol parsing), so these are the working stand-ins — noted in the input hint. - editorLines() renders the buffer as styled rows with the caret at the cursor, splitting on \n and capping at maxInputLines (last rows kept). Both the idle launcher input and the in-session bottom bar use it and grow to fit; View() sizes the bottom bar via inputBarHeight() instead of the fixed inputH. - Footer gains a "^J newline" hint in insert mode (non-filter); the launcher sub-line shows "⌥↵/^J newline". New "compose" preview kind + editor_lines_test (split + height cap). Co-Authored-By: Claude Opus 4.8 --- apps/tui-go/internal/app/demo.go | 9 ++ apps/tui-go/internal/app/editor_lines_test.go | 38 ++++++++ apps/tui-go/internal/app/update.go | 13 +++ apps/tui-go/internal/app/view.go | 96 ++++++++++++++----- 4 files changed, 133 insertions(+), 23 deletions(-) create mode 100644 apps/tui-go/internal/app/editor_lines_test.go diff --git a/apps/tui-go/internal/app/demo.go b/apps/tui-go/internal/app/demo.go index 1474bcb9..31ff58ed 100644 --- a/apps/tui-go/internal/app/demo.go +++ b/apps/tui-go/internal/app/demo.go @@ -22,6 +22,15 @@ func PreviewFrame(kind string, w, h int) string { m.bgUpdates = 3 m.selectedID = "04a546aa" + case "compose": + m.connected = true + m.currentModel = "llama-cpp:default" + m.sessions = sampleSessions() + m.editMode = ModeInsert + m.inputMode = ModeRouter + m.inputBuffer = "fix the healthcheck script\nso it retries three times\nbefore it fails" + m.inputCursor = len(m.inputBuffer) + case "resume": m.connected = true m.currentModel = "llama-cpp:default" diff --git a/apps/tui-go/internal/app/editor_lines_test.go b/apps/tui-go/internal/app/editor_lines_test.go new file mode 100644 index 00000000..3c4ec62b --- /dev/null +++ b/apps/tui-go/internal/app/editor_lines_test.go @@ -0,0 +1,38 @@ +package app + +import ( + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" +) + +// editorLines splits the buffer on newlines into one display row each, in order. +func TestEditorLinesSplitsOnNewline(t *testing.T) { + m := NewModel(nil) + m.theme = NewTheme(SoftBlue) + m.inputBuffer = "one\ntwo\nthree" + m.inputCursor = len(m.inputBuffer) + + lines := m.editorLines() + if len(lines) != 3 { + t.Fatalf("want 3 lines, got %d", len(lines)) + } + for i, want := range []string{"one", "two", "three"} { + if got := strings.TrimRight(ansi.Strip(lines[i]), " ▏"); got != want { + t.Errorf("line %d = %q, want %q", i, got, want) + } + } +} + +// A buffer taller than maxInputLines shows only the last maxInputLines rows (cursor tail). +func TestEditorLinesCapsHeight(t *testing.T) { + m := NewModel(nil) + m.theme = NewTheme(SoftBlue) + m.inputBuffer = strings.Repeat("x\n", maxInputLines+4) + "last" + m.inputCursor = len(m.inputBuffer) + + if got := len(m.editorLines()); got != maxInputLines { + t.Fatalf("want capped at %d lines, got %d", maxInputLines, got) + } +} diff --git a/apps/tui-go/internal/app/update.go b/apps/tui-go/internal/app/update.go index 22963c56..e3486dcf 100644 --- a/apps/tui-go/internal/app/update.go +++ b/apps/tui-go/internal/app/update.go @@ -440,7 +440,20 @@ func (m Model) handleInsertKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { m.inputMode = ModeRouter } case tea.KeyEnter: + // Alt+Enter inserts a newline; a bare Enter submits. (Shift+Enter is not + // distinguishable from Enter in this terminal stack — see Ctrl+J below.) + if k.Alt && m.inputMode != ModeFilter { + m.appendRunes("\n") + return m, nil + } return m.submit() + case tea.KeyCtrlJ: + // Reliable "newline without sending" (Ctrl+J is the literal LF key) for the + // single-line filter excluded. + if m.inputMode != ModeFilter { + m.appendRunes("\n") + } + return m, nil case tea.KeyBackspace: m.backspace() m.syncFilter() diff --git a/apps/tui-go/internal/app/view.go b/apps/tui-go/internal/app/view.go index 6446534c..3ff10db0 100644 --- a/apps/tui-go/internal/app/view.go +++ b/apps/tui-go/internal/app/view.go @@ -38,7 +38,7 @@ func (m Model) View() string { } ds := m.displayState() - bottom, bottomH := m.renderInput(), inputH + bottom, bottomH := m.renderInput(), m.inputBarHeight() switch { case ds == StateApproval: bottomH = m.approvalBandHeight() @@ -230,6 +230,9 @@ func (m Model) renderFooter() string { switch { case m.editMode == ModeInsert: hints = []string{hint("enter", "send"), hint("esc", "normal"), hint("↑↓", "history")} + if m.inputMode != ModeFilter { + hints = append(hints, hint("^J", "newline")) + } // The `/` command menu only triggers on an empty chat line (mid-line `/` is literal). if m.inputMode == ModeRouter && m.inputBuffer == "" { hints = append(hints, hint("/", "cmds")) @@ -326,30 +329,67 @@ func (m Model) renderLauncher(w, h int) string { return lipgloss.JoinHorizontal(lipgloss.Top, left, rail) } -// launcherInput renders the compact input box plus a status sub-line ( · -// left, action hints right). +// maxInputLines caps how many lines a growing (multi-line) input box shows; beyond it the +// box scrolls to keep the cursor's tail in view. +const maxInputLines = 6 + +// editorLines renders the input buffer as styled display lines with the caret at the cursor, +// so multi-line input (Ctrl+J / Alt+Enter) shows across rows. Tabs are flattened to a space. +func (m Model) editorLines() []string { + t := m.theme + caret := "" + if m.editMode == ModeInsert && m.caretVisible() { + caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Render("▏") + } + buf := m.inputBuffer + cur := m.inputCursor + if cur > len(buf) { + cur = len(buf) + } + if cur < 0 { + cur = 0 + } + style := func(s string) string { return t.span(strings.ReplaceAll(s, "\t", " "), t.P.FgStrong) } + before := strings.Split(buf[:cur], "\n") + after := strings.Split(buf[cur:], "\n") + var lines []string + for _, ln := range before[:len(before)-1] { + lines = append(lines, style(ln)) + } + lines = append(lines, style(before[len(before)-1])+caret+style(after[0])) + for _, ln := range after[1:] { + lines = append(lines, style(ln)) + } + if len(lines) > maxInputLines { + lines = lines[len(lines)-maxInputLines:] + } + return lines +} + +// launcherInput renders the compact (growing) input box plus a status sub-line +// ( · left, action hints right). func (m Model) launcherInput(w int) string { t := m.theme insert := m.editMode == ModeInsert prompt := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render("› ") - caret := t.span(" ", t.P.Bg) - if insert && m.caretVisible() { - caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Render("▏") + indent := t.span(" ", t.P.Bg) + var content []string + if m.inputBuffer == "" && !insert { + content = []string{prompt + t.span("Ask anything…", t.P.Faint)} + } else { + for i, ln := range m.editorLines() { + if i == 0 { + content = append(content, prompt+ln) + } else { + content = append(content, indent+ln) + } + } } - var line string - switch { - case m.inputBuffer == "" && !insert: - line = prompt + t.span("Ask anything…", t.P.Faint) - case m.inputBuffer == "": - line = prompt + caret + t.span("Ask anything…", t.P.Faint) - default: - line = prompt + t.span(flattenForDisplay(m.inputBuffer), t.P.FgStrong) + caret - } - box := t.box("", []string{line}, w, 3, insert) + box := t.box("", content, w, len(content)+2, insert) wf := lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.Bg).Render(m.launcherWfName()) leftSub := " " + wf + t.span(" · ", t.P.Faint) + t.span(m.currentModel, t.P.Faint) - hints := t.span("Tab workflow · enter ↵ ", t.P.Faint) + hints := t.span("Tab wf · ⌥↵/^J newline · enter ↵ ", t.P.Faint) return box + "\n" + m.justify(leftSub, hints, w, t.P.Bg) } @@ -449,14 +489,14 @@ func (m Model) renderInput() string { if insert && m.caretVisible() { caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Render("▏") } - var line string + var content []string switch { case m.inputBuffer == "" && !insert: - line = t.span(placeholder, t.P.Faint) + content = []string{t.span(placeholder, t.P.Faint)} case m.inputBuffer == "": - line = caret + t.span(placeholder, t.P.Faint) + content = []string{caret + t.span(placeholder, t.P.Faint)} default: - line = t.span(flattenForDisplay(m.inputBuffer), t.P.FgStrong) + caret + content = m.editorLines() } // status sub-line: · · @@ -482,8 +522,18 @@ func (m Model) renderInput() string { sub += t.span(" · ", t.P.Faint) + t.span(mode, t.P.Dim) - body := []string{line, sub} - return t.box("", body, m.width, inputH, insert) + body := append(content, sub) + return t.box("", body, m.width, len(body)+2, insert) +} + +// inputBarHeight is the bottom input bar's current height (it grows with multi-line input): +// content lines (capped at maxInputLines) + the status sub-line + 2 borders. +func (m Model) inputBarHeight() int { + n := 1 + if m.inputBuffer != "" { + n = len(m.editorLines()) + } + return n + 1 + 2 } // --- body row builders --- From 6c792b83e2698a9825c54af5b630d9ed0fb7b244 Mon Sep 17 00:00:00 2001 From: kami Date: Mon, 22 Jun 2026 14:47:23 +0000 Subject: [PATCH 067/107] feat(tui-go): in-session right panel cycles events / changes / off (d) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Press d (or the "panel" palette command) in-session to cycle the right panel: - events — the live event stream (default, unchanged) - changes — a token-usage line (tokens + turns, summed from the transcript's TurnMetrics) over a git-status-style list of files the session has written, each with summed +/− from its diff. ^x still opens the full diff. - off — hide the panel; the output transcript takes the full width. changesRows derives everything from data already in the model (router-turn metrics + the tool entries' unified diffs), so no new protocol. Footer + ? help updated; "changes" preview kind added. Co-Authored-By: Claude Opus 4.8 --- apps/tui-go/internal/app/demo.go | 18 ++++++ .../tui-go/internal/app/help_complete_test.go | 1 + apps/tui-go/internal/app/model.go | 3 + apps/tui-go/internal/app/overlays.go | 1 + apps/tui-go/internal/app/update.go | 13 ++++ apps/tui-go/internal/app/view.go | 62 ++++++++++++++++++- 6 files changed, 95 insertions(+), 3 deletions(-) diff --git a/apps/tui-go/internal/app/demo.go b/apps/tui-go/internal/app/demo.go index 31ff58ed..cc0cb440 100644 --- a/apps/tui-go/internal/app/demo.go +++ b/apps/tui-go/internal/app/demo.go @@ -22,6 +22,24 @@ func PreviewFrame(kind string, w, h int) string { m.bgUpdates = 3 m.selectedID = "04a546aa" + case "changes": + m.connected = true + m.currentModel = "llama-cpp:default" + m.sessions = sampleSessions() + m.selectedID = "04a546aa" + m.sessionEntered = true + m.rightPanel = 1 + if s := m.session("04a546aa"); s != nil { + s.CurrentStage = "write_script" + } + m.routerMessages["04a546aa"] = []RouterEntry{ + {Role: "user", Content: "add a healthcheck script and document it"}, + {Role: "router", Content: "I'll write the script.", Metrics: &TurnMetrics{LatencyMs: 1200, TotalTokens: 340}}, + {Role: "tool", Content: "--- a/healthcheck.sh\n+++ b/healthcheck.sh\n@@ -0,0 +1,4 @@\n+#!/usr/bin/env bash\n+curl -sf localhost:8080/health\n+echo ok\n+exit 0\n"}, + {Role: "router", Content: "Now the docs.", Metrics: &TurnMetrics{LatencyMs: 820, TotalTokens: 150}}, + {Role: "tool", Content: "--- a/README.md\n+++ b/README.md\n@@ -1,1 +1,2 @@\n existing line\n+## Health\n"}, + } + case "compose": m.connected = true m.currentModel = "llama-cpp:default" diff --git a/apps/tui-go/internal/app/help_complete_test.go b/apps/tui-go/internal/app/help_complete_test.go index 4f995079..0489f517 100644 --- a/apps/tui-go/internal/app/help_complete_test.go +++ b/apps/tui-go/internal/app/help_complete_test.go @@ -25,6 +25,7 @@ func TestHelpCoversEveryKeybind(t *testing.T) { "back to the session list", // l // session "compose a message", // i + "right panel: events / changes", // d "toggle chat / steering mode", // s "show workflows", // w "copy the selected message", // y diff --git a/apps/tui-go/internal/app/model.go b/apps/tui-go/internal/app/model.go index ebce1517..5e8dc0e2 100644 --- a/apps/tui-go/internal/app/model.go +++ b/apps/tui-go/internal/app/model.go @@ -278,6 +278,9 @@ type Model struct { diffScrollOffset int modalScroll int // body scroll for tall fixed-content modals (help, stats) eventStripShown bool + // in-session right panel: 0 = events, 1 = changes (token usage + changed files), + // 2 = off (output full-width). Cycled with `d`. + rightPanel int // artifact viewer (OverlayArtifacts) — populated by the artifact.list response artifacts []protocol.ArtifactDto diff --git a/apps/tui-go/internal/app/overlays.go b/apps/tui-go/internal/app/overlays.go index a9655a43..c648f457 100644 --- a/apps/tui-go/internal/app/overlays.go +++ b/apps/tui-go/internal/app/overlays.go @@ -830,6 +830,7 @@ func (m Model) helpBody() []string { }) section("session", []kb{ {"i", "compose a message"}, + {"d", "right panel: events / changes / off"}, {"s", "toggle chat / steering mode"}, {"w", "show workflows (idle screen)"}, {"y", "copy the selected message (OSC52)"}, diff --git a/apps/tui-go/internal/app/update.go b/apps/tui-go/internal/app/update.go index e3486dcf..61d5b8af 100644 --- a/apps/tui-go/internal/app/update.go +++ b/apps/tui-go/internal/app/update.go @@ -310,6 +310,11 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { if ds == StateIdle { m.cycleLauncherWf() } + case "d": + // In-session: cycle the right panel (events → changes → off). + if ds == StateInSession { + m.cycleRightPanel() + } case "l": if ds != StateIdle { m.sessionEntered = false @@ -1090,6 +1095,7 @@ func paletteCommands() []paletteCmd { {"grants", "G", "grants", "view / revoke standing grants"}, {"statusbar", "", "status bar", "show / hide status-bar segments"}, {"rail", "", "idle rail", "show / hide the idle status + keys rail"}, + {"panel", "d", "right panel", "in-session: events → changes → off"}, {"actions", "", "inline actions", "show / hide tool & action rows in output"}, {"help", "?", "help", "keybinding cheat-sheet"}, {"mode", "s", "toggle mode", "switch chat / steering"}, @@ -1140,6 +1146,8 @@ func (m Model) execPalette(id string) (tea.Model, tea.Cmd) { m.sbIndex = 0 case "rail": m.railHidden = !m.railHidden + case "panel": + m.cycleRightPanel() case "actions": m.toggleInlineActions() case "help": @@ -1187,6 +1195,11 @@ func (m *Model) cycleLauncherWf() { m.launcherWf = (m.launcherWf + 1) % (len(m.workflows) + 1) } +// cycleRightPanel advances the in-session right panel: events → changes → off → events. +func (m *Model) cycleRightPanel() { + m.rightPanel = (m.rightPanel + 1) % 3 +} + func (m *Model) cycleChatMode() { if m.chatMode == ChatModeChat { m.chatMode = ChatModeSteering diff --git a/apps/tui-go/internal/app/view.go b/apps/tui-go/internal/app/view.go index 3ff10db0..ff7f4a9d 100644 --- a/apps/tui-go/internal/app/view.go +++ b/apps/tui-go/internal/app/view.go @@ -261,7 +261,7 @@ func (m Model) renderFooter() string { } else { // Trimmed to fit ~100 cols: tools/stats/model are all reachable via `p cmds`, // so the footer keeps the high-traffic keys + the new transcript nav/copy. - hints = []string{hint("i", "message"), hint("^↑↓", "msgs"), hint("y", "copy"), hint("e", "events"), hint("t", "tools"), hint("l", "back"), hint("p", "cmds"), hint("q", "quit")} + hints = []string{hint("i", "message"), hint("^↑↓", "msgs"), hint("y", "copy"), hint("e", "events"), hint("d", "panel"), hint("l", "back"), hint("p", "cmds"), hint("q", "quit")} } if m.copiedFlash != 0 && m.frame-m.copiedFlash < copiedFlashFrames { hints = append(hints, lipgloss.NewStyle().Foreground(t.P.OK).Background(bg).Bold(true).Render("✓ copied")) @@ -295,12 +295,68 @@ func (m Model) renderMain(h int) string { leftW := m.width * 63 / 100 rightW := m.width - leftW - // In-session / approval: output transcript over the event stream. + // In-session / approval. The right panel cycles (d): events, changes (token usage + + // changed files), or off (output takes the full width). + if m.rightPanel == 2 { + return m.theme.box("output", m.routerRows(m.width-4, h-2), m.width, h, true) + } left := m.theme.box("output", m.routerRows(leftW-4, h-2), leftW, h, true) - right := m.theme.box("events", m.eventRows(rightW-4, h), rightW, h, false) + var right string + if m.rightPanel == 1 { + right = m.theme.box("changes", m.changesRows(rightW-4, h), rightW, h, false) + } else { + right = m.theme.box("events", m.eventRows(rightW-4, h), rightW, h, false) + } return lipgloss.JoinHorizontal(lipgloss.Top, left, right) } +// changesRows is the in-session "changes" panel: a token-usage line plus a git-status-style +// list of files the session has written (summed +/- from each write's diff). ^x opens the +// full diff. Drives the right panel when rightPanel == 1. +func (m Model) changesRows(w, h int) []string { + t := m.theme + turns, toks := 0, 0 + for _, e := range m.routerMessages[m.selectedID] { + if e.Role == "router" && e.Metrics != nil { + turns++ + toks += e.Metrics.TotalTokens + } + } + rows := []string{ + t.span("tokens ", t.P.Faint) + t.span(itoa(toks), t.P.FgStrong) + + t.span(" · turns ", t.P.Faint) + t.span(itoa(turns), t.P.Dim), + "", + } + + type chg struct{ add, del int } + var order []string + seen := map[string]*chg{} + for _, e := range m.routerMessages[m.selectedID] { + if e.Role != "tool" || !isUnifiedDiff(e.Content) { + continue + } + p, a, d := diffSummary(e.Content) + if seen[p] == nil { + seen[p] = &chg{} + order = append(order, p) + } + seen[p].add += a + seen[p].del += d + } + if len(order) == 0 { + return append(rows, t.span("no file changes yet", t.P.Faint)) + } + rows = append(rows, t.span("changes (^x for full diff)", t.P.Faint)) + for _, p := range order { + c := seen[p] + icon := lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.Bg).Render("✎ ") + delta := lipgloss.NewStyle().Foreground(t.P.OK).Background(t.P.Bg).Render("+"+itoa(c.add)) + + t.span(" ", t.P.Bg) + lipgloss.NewStyle().Foreground(t.P.Bad).Background(t.P.Bg).Render("−"+itoa(c.del)) + rows = append(rows, icon+t.span(truncate(p, w-16), t.P.Fg)+" "+delta) + } + return rows +} + // renderLauncher is the idle screen: a compact, opencode-style input centered in the main // area, with a hideable right rail of status + quick keys. The session list isn't shown — // it's a keypress away (R). The input's lower-right shows · , Tab-cycled. From d247b19608d2d560f7d0ab63b391ed16b1d80384 Mon Sep 17 00:00:00 2001 From: kami Date: Mon, 22 Jun 2026 15:59:55 +0000 Subject: [PATCH 068/107] =?UTF-8?q?tui:=20migrate=20Bubble=20Tea=20v1=20?= =?UTF-8?q?=E2=86=92=20v2=20(charm.land),=20enable=20Shift+Enter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the Go TUI from github.com/charmbracelet/{bubbletea,lipgloss} to the v2 ecosystem at charm.land/{bubbletea,lipgloss}/v2 (Go 1.25). v2's kitty keyboard disambiguation is on by default, so Shift+Enter now reliably inserts a newline in the composer (Ctrl+J / Alt+Enter kept as fallbacks for non-kitty terminals). Approach: rather than rewrite ~190 key-match sites to v2's (Code, Mod) idiom, a small shim (internal/app/key.go) converts a v2 KeyPressMsg into the v1-shaped keyMsg the handlers already expect, at the single Update boundary. The rest is mechanical: - key constants tea.Key* → shim consts; tea.KeyMsg → keyMsg. - lipgloss.Color is now a func returning color.Color, not a type → fields/params retyped to image/color.Color (theme/diff/overlays/view). - v2 View() returns tea.View: render() builds the string, View() wraps it and carries AltScreen (alt-screen is a per-frame View field now, not a program opt). - WithWhitespaceBackground/Foreground → WithWhitespaceStyle(Style). - preview: drop lipgloss.SetColorProfile (v2 renders truecolor by default). Build, vet, gofmt, and the full test suite are green; preview renders truecolor across kinds. Co-Authored-By: Claude Opus 4.8 --- apps/tui-go/cmd/preview/main.go | 7 +- apps/tui-go/go.mod | 31 +-- apps/tui-go/go.sum | 59 ++--- .../internal/app/adaptive_layout_test.go | 6 +- apps/tui-go/internal/app/approval_test.go | 13 +- apps/tui-go/internal/app/clarcard.go | 32 +-- apps/tui-go/internal/app/clarcard_test.go | 19 +- apps/tui-go/internal/app/cmdcard.go | 2 +- apps/tui-go/internal/app/config_overlay.go | 22 +- .../internal/app/config_overlay_test.go | 11 +- apps/tui-go/internal/app/demo.go | 2 +- apps/tui-go/internal/app/diff.go | 5 +- apps/tui-go/internal/app/ideacard.go | 2 +- apps/tui-go/internal/app/ideacard_test.go | 3 +- apps/tui-go/internal/app/key.go | 123 +++++++++++ .../internal/app/overlay_composite_test.go | 2 +- apps/tui-go/internal/app/overlays.go | 11 +- apps/tui-go/internal/app/proposecard.go | 26 +-- apps/tui-go/internal/app/proposecard_test.go | 17 +- .../tui-go/internal/app/render_matrix_test.go | 2 +- apps/tui-go/internal/app/sessions_overlay.go | 14 +- .../internal/app/sessions_overlay_test.go | 16 +- apps/tui-go/internal/app/slash_test.go | 6 +- apps/tui-go/internal/app/theme.go | 56 ++--- apps/tui-go/internal/app/update.go | 207 +++++++++--------- apps/tui-go/internal/app/view.go | 33 ++- apps/tui-go/main.go | 5 +- 27 files changed, 432 insertions(+), 300 deletions(-) create mode 100644 apps/tui-go/internal/app/key.go diff --git a/apps/tui-go/cmd/preview/main.go b/apps/tui-go/cmd/preview/main.go index 1dee08c9..0e569cb3 100644 --- a/apps/tui-go/cmd/preview/main.go +++ b/apps/tui-go/cmd/preview/main.go @@ -7,14 +7,13 @@ import ( "os" "strconv" - "github.com/charmbracelet/lipgloss" "github.com/correx/tui-go/internal/app" - "github.com/muesli/termenv" ) func main() { - lipgloss.SetColorProfile(termenv.TrueColor) - + // lipgloss v2 renders full truecolor ANSI by default (color downsampling now + // lives in the terminal writer, not a global profile), which is exactly what a + // static screenshot frame wants — so no color-profile setup is needed. kind := "idle" if len(os.Args) > 1 { kind = os.Args[1] diff --git a/apps/tui-go/go.mod b/apps/tui-go/go.mod index 0a9c3c3e..45c4557d 100644 --- a/apps/tui-go/go.mod +++ b/apps/tui-go/go.mod @@ -1,44 +1,45 @@ module github.com/correx/tui-go -go 1.24.2 +go 1.25.0 require ( + charm.land/bubbletea/v2 v2.0.7 + charm.land/lipgloss/v2 v2.0.4 github.com/aymanbagabas/go-osc52/v2 v2.0.1 - github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/glamour v1.0.0 - github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 - github.com/charmbracelet/x/ansi v0.11.6 + github.com/charmbracelet/x/ansi v0.11.7 github.com/gorilla/websocket v1.5.3 - github.com/muesli/termenv v0.16.0 ) require ( github.com/alecthomas/chroma/v2 v2.20.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect - github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect github.com/charmbracelet/x/term v0.2.2 // indirect - github.com/clipperhouse/displaywidth v0.9.0 // indirect - github.com/clipperhouse/stringish v0.1.1 // indirect - github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect - github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/gorilla/css v1.0.1 // indirect - github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/mattn/go-runewidth v0.0.23 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect - github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/reflow v0.3.0 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yuin/goldmark v1.7.13 // indirect github.com/yuin/goldmark-emoji v1.0.6 // indirect golang.org/x/net v0.38.0 // indirect - golang.org/x/sys v0.38.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.45.0 // indirect golang.org/x/term v0.36.0 // indirect golang.org/x/text v0.30.0 // indirect ) diff --git a/apps/tui-go/go.sum b/apps/tui-go/go.sum index 321fbe29..589ecead 100644 --- a/apps/tui-go/go.sum +++ b/apps/tui-go/go.sum @@ -1,3 +1,7 @@ +charm.land/bubbletea/v2 v2.0.7 h1:7qw2tTAVar7m7klOPBYfTB0mniv/RuexsYwMRNxSeL0= +charm.land/bubbletea/v2 v2.0.7/go.mod h1:DGW2q8gvzHnOpMpZTORs0aySVHCox5C+2Svk0fci1qs= +charm.land/lipgloss/v2 v2.0.4 h1:lcPeVtcp23SNra7lHy8iYE4UC2aIipVQ47sbGyyxR5Q= +charm.land/lipgloss/v2 v2.0.4/go.mod h1:0653x8epbZSzdDfO/XPS1a/uYPOBeSsCssOpJOqDzik= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw= @@ -6,57 +10,53 @@ github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= -github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= +github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= -github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= -github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= -github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= -github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08= github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo= github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= -github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= -github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654 h1:FpSYhY28ucg9ZRr+2wj67FAQ0Ey5yiK0072PmRDJNek= +github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654/go.mod h1:hFpumms29Smx3LStRfku8vcCTBe1Kq8aCXtHUJa3mjY= +github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= +github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= -github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30= -github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= -github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= -github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= -github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= -github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= -github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= -github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= -github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= -github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= +github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= -github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= -github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= -github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= +github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= @@ -77,10 +77,11 @@ golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= -golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= diff --git a/apps/tui-go/internal/app/adaptive_layout_test.go b/apps/tui-go/internal/app/adaptive_layout_test.go index c1a27f9a..101f9148 100644 --- a/apps/tui-go/internal/app/adaptive_layout_test.go +++ b/apps/tui-go/internal/app/adaptive_layout_test.go @@ -4,7 +4,7 @@ import ( "strings" "testing" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" ) // inSessionModel builds an in-session model with deliberately long fields (model name, @@ -40,7 +40,7 @@ func assertNoOverflow(t *testing.T, out string, w int) { func TestViewNeverOverflowsWidth(t *testing.T) { for _, w := range []int{40, 50, 60, 80, 95, 96, 110, 160} { m := inSessionModel(w, 30) - assertNoOverflow(t, m.View(), w) + assertNoOverflow(t, m.render(), w) } } @@ -51,7 +51,7 @@ func TestStatsOverlayNeverOverflowsWidth(t *testing.T) { m.overlay = OverlayStats m.statsFor = m.selectedID m.stats = sampleStats() - assertNoOverflow(t, m.View(), w) + assertNoOverflow(t, m.render(), w) } } diff --git a/apps/tui-go/internal/app/approval_test.go b/apps/tui-go/internal/app/approval_test.go index 072f549c..e0c78585 100644 --- a/apps/tui-go/internal/app/approval_test.go +++ b/apps/tui-go/internal/app/approval_test.go @@ -5,7 +5,6 @@ import ( "strings" "testing" - tea "github.com/charmbracelet/bubbletea" "github.com/correx/tui-go/internal/protocol" "github.com/correx/tui-go/internal/ws" ) @@ -40,12 +39,12 @@ func apprReq(reqID, tier string) protocol.ServerMessage { } // applyKey drives a key through the production handleKey entry and returns the model. -func applyKey(m Model, k tea.KeyMsg) Model { +func applyKey(m Model, k keyMsg) Model { updated, _ := m.handleKey(k) return updated.(Model) } -func runeKey(r rune) tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}} } +func runeKey(r rune) keyMsg { return keyMsg{Type: keyRunes, Runes: []rune{r}} } // decodeApproval pulls the single ApprovalResponse frame off the client and returns // its request id + decision. Fails if there isn't exactly one decision frame. @@ -146,7 +145,7 @@ func TestApprovalHighTierArmCancelledByNavKey(t *testing.T) { // no decision may be emitted. m, client := approvalModel(apprReq("req-1", "T3"), apprReq("req-2", "T3")) m = applyKey(m, runeKey('y')) // arm req-1 - m = applyKey(m, tea.KeyMsg{Type: tea.KeyDown}) + m = applyKey(m, keyMsg{Type: keyDown}) if m.approvalArmed { t.Fatalf("nav key should disarm") } @@ -168,7 +167,7 @@ func TestApprovalQueueNavigatesAndCounts(t *testing.T) { t.Fatalf("band should show queue count 1/3:\n%s", out) } // ↓ advances the selection; Pending follows the cursor. - m = applyKey(m, tea.KeyMsg{Type: tea.KeyDown}) + m = applyKey(m, keyMsg{Type: keyDown}) s = m.session("s1") if s.PendingIdx != 1 || s.Pending.RequestID != "req-2" { t.Fatalf("↓ should select req-2, got idx=%d id=%s", s.PendingIdx, s.Pending.RequestID) @@ -178,7 +177,7 @@ func TestApprovalQueueNavigatesAndCounts(t *testing.T) { t.Fatalf("band should show 2/3 after ↓:\n%s", out) } // ↑ wraps back. - m = applyKey(m, tea.KeyMsg{Type: tea.KeyUp}) + m = applyKey(m, keyMsg{Type: keyUp}) if m.session("s1").PendingIdx != 0 { t.Fatalf("↑ should return to index 0") } @@ -238,7 +237,7 @@ func TestApprovalRejectAndSteerRoute(t *testing.T) { for _, r := range "use sudo" { m2 = applyKey(m2, runeKey(r)) } - m2 = applyKey(m2, tea.KeyMsg{Type: tea.KeyEnter}) + m2 = applyKey(m2, keyMsg{Type: keyEnter}) frames := client2.Drain() var note string for _, f := range frames { diff --git a/apps/tui-go/internal/app/clarcard.go b/apps/tui-go/internal/app/clarcard.go index 352f12b2..a474d778 100644 --- a/apps/tui-go/internal/app/clarcard.go +++ b/apps/tui-go/internal/app/clarcard.go @@ -3,8 +3,8 @@ package app import ( "strings" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "github.com/correx/tui-go/internal/protocol" ) @@ -92,7 +92,7 @@ func (m Model) clarAnswerValue(i int, q ClarQuestion) string { // --- key handling --- -func (m Model) handleClarificationKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { +func (m Model) handleClarificationKey(k keyMsg) (tea.Model, tea.Cmd) { s := m.session(m.selectedID) if s == nil || s.Clar == nil { return m, nil @@ -102,21 +102,21 @@ func (m Model) handleClarificationKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { return m.handleClarTyping(k), nil } switch k.Type { - case tea.KeyEsc: + case keyEsc: m.clarDismissed = true - case tea.KeyUp: + case keyUp: m.clarFocusMove(-1, qs) - case tea.KeyDown: + case keyDown: m.clarFocusMove(1, qs) - case tea.KeyLeft: + case keyLeft: m.clarCursorMove(-1, qs) - case tea.KeyRight: + case keyRight: m.clarCursorMove(1, qs) - case tea.KeySpace: + case keySpace: m.clarSelect(qs) - case tea.KeyEnter: + case keyEnter: return m.clarSubmit() - case tea.KeyRunes: + case keyRunes: switch string(k.Runes) { case "k": m.clarFocusMove(-1, qs) @@ -136,24 +136,24 @@ func (m Model) handleClarificationKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } -func (m Model) handleClarTyping(k tea.KeyMsg) tea.Model { +func (m Model) handleClarTyping(k keyMsg) tea.Model { if m.clarFocus >= len(m.clarText) { return m } switch k.Type { - case tea.KeyEsc: + case keyEsc: m.clarTyping = false - case tea.KeyEnter: + case keyEnter: m.clarTyping = false // committing custom text supersedes any chosen option for this question if m.clarFocus < len(m.clarChosen) { m.clarChosen[m.clarFocus] = map[int]bool{} } - case tea.KeyBackspace: + case keyBackspace: if t := m.clarText[m.clarFocus]; len(t) > 0 { m.clarText[m.clarFocus] = t[:len(t)-1] } - case tea.KeyRunes, tea.KeySpace: + case keyRunes, keySpace: m.clarText[m.clarFocus] += string(k.Runes) } return m diff --git a/apps/tui-go/internal/app/clarcard_test.go b/apps/tui-go/internal/app/clarcard_test.go index 2a50e8f3..1b8e031c 100644 --- a/apps/tui-go/internal/app/clarcard_test.go +++ b/apps/tui-go/internal/app/clarcard_test.go @@ -4,7 +4,6 @@ import ( "strings" "testing" - tea "github.com/charmbracelet/bubbletea" "github.com/correx/tui-go/internal/protocol" "github.com/correx/tui-go/internal/ws" ) @@ -43,13 +42,13 @@ func TestClarificationEntersStateAndRenders(t *testing.T) { func TestClarificationSelectAndSubmit(t *testing.T) { m := clarModel() // move the option cursor to "Vue" (index 1) and select it - m = applyClarKey(m, tea.KeyMsg{Type: tea.KeyRight}) - m = applyClarKey(m, tea.KeyMsg{Type: tea.KeySpace}) + m = applyClarKey(m, keyMsg{Type: keyRight}) + m = applyClarKey(m, keyMsg{Type: keySpace}) if !m.clarChosenAt(0, 1) { t.Fatalf("expected option 1 chosen, chosen=%v", m.clarChosen) } // submit - m = applyClarKey(m, tea.KeyMsg{Type: tea.KeyEnter}) + m = applyClarKey(m, keyMsg{Type: keyEnter}) if s := m.session("s1"); s == nil || s.Clar != nil { t.Fatalf("expected clarification cleared after submit") } @@ -68,8 +67,8 @@ func TestClarificationSubmitGuardWaitsForAllAnswers(t *testing.T) { }, }) // answer only the first question, then attempt submit - m = applyClarKey(m, tea.KeyMsg{Type: tea.KeySpace}) - m = applyClarKey(m, tea.KeyMsg{Type: tea.KeyEnter}) + m = applyClarKey(m, keyMsg{Type: keySpace}) + m = applyClarKey(m, keyMsg{Type: keyEnter}) if s := m.session("s1"); s == nil || s.Clar == nil { t.Fatalf("expected clarification to remain with partial answers") } @@ -80,14 +79,14 @@ func TestClarificationSubmitGuardWaitsForAllAnswers(t *testing.T) { func TestClarificationCustomTextAnswer(t *testing.T) { m := clarModel() - m = applyClarKey(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("e")}) + m = applyClarKey(m, keyMsg{Type: keyRunes, Runes: []rune("e")}) if !m.clarTyping { t.Fatalf("expected typing mode after 'e'") } for _, r := range "Solid" { - m = applyClarKey(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + m = applyClarKey(m, keyMsg{Type: keyRunes, Runes: []rune{r}}) } - m = applyClarKey(m, tea.KeyMsg{Type: tea.KeyEnter}) + m = applyClarKey(m, keyMsg{Type: keyEnter}) if m.clarTyping { t.Fatalf("expected typing committed") } @@ -99,7 +98,7 @@ func TestClarificationCustomTextAnswer(t *testing.T) { } } -func applyClarKey(m Model, k tea.KeyMsg) Model { +func applyClarKey(m Model, k keyMsg) Model { updated, _ := m.handleClarificationKey(k) return updated.(Model) } diff --git a/apps/tui-go/internal/app/cmdcard.go b/apps/tui-go/internal/app/cmdcard.go index 5ad86f62..7c2b8c6e 100644 --- a/apps/tui-go/internal/app/cmdcard.go +++ b/apps/tui-go/internal/app/cmdcard.go @@ -5,7 +5,7 @@ import ( "sort" "strings" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" ) // cmdcard.go is layer 1 of tui-requirements §5 — the deterministic command-approval diff --git a/apps/tui-go/internal/app/config_overlay.go b/apps/tui-go/internal/app/config_overlay.go index a33326e8..490ca633 100644 --- a/apps/tui-go/internal/app/config_overlay.go +++ b/apps/tui-go/internal/app/config_overlay.go @@ -3,8 +3,8 @@ package app import ( "strings" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "github.com/correx/tui-go/internal/protocol" ) @@ -23,40 +23,40 @@ func (m *Model) openConfig() { // handleConfigKey owns every key while the config overlay is open. In edit mode it captures the // value being typed; otherwise it navigates fields, stages edits, and saves. -func (m Model) handleConfigKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { +func (m Model) handleConfigKey(k keyMsg) (tea.Model, tea.Cmd) { if m.configEditing { switch k.Type { - case tea.KeyEsc: + case keyEsc: m.configEditing = false m.configEditBuf = "" - case tea.KeyEnter: + case keyEnter: if m.configIndex >= 0 && m.configIndex < len(m.configFields) { m.configStaged[m.configFields[m.configIndex].Key] = m.configEditBuf } m.configEditing = false m.configEditBuf = "" - case tea.KeyBackspace: + case keyBackspace: if n := len(m.configEditBuf); n > 0 { m.configEditBuf = m.configEditBuf[:n-1] } - case tea.KeyRunes, tea.KeySpace: + case keyRunes, keySpace: m.configEditBuf += string(k.Runes) } return m, nil } switch { - case k.Type == tea.KeyEsc || runeIs(k, "g"): + case k.Type == keyEsc || runeIs(k, "g"): m.overlay = OverlayNone - case k.Type == tea.KeyUp || runeIs(k, "k"): + case k.Type == keyUp || runeIs(k, "k"): if m.configIndex > 0 { m.configIndex-- } - case k.Type == tea.KeyDown || runeIs(k, "j"): + case k.Type == keyDown || runeIs(k, "j"): if m.configIndex < len(m.configFields)-1 { m.configIndex++ } - case k.Type == tea.KeyEnter || k.Type == tea.KeySpace: + case k.Type == keyEnter || k.Type == keySpace: m = m.actOnConfigField() case runeIs(k, "s"): if len(m.configStaged) > 0 { diff --git a/apps/tui-go/internal/app/config_overlay_test.go b/apps/tui-go/internal/app/config_overlay_test.go index 4d0ccd4e..4ff007f5 100644 --- a/apps/tui-go/internal/app/config_overlay_test.go +++ b/apps/tui-go/internal/app/config_overlay_test.go @@ -4,7 +4,6 @@ import ( "strings" "testing" - tea "github.com/charmbracelet/bubbletea" "github.com/correx/tui-go/internal/protocol" ) @@ -23,7 +22,7 @@ func configModel() Model { func TestConfigToggleBoolStagesFlippedValue(t *testing.T) { m := configModel() - updated, _ := m.handleConfigKey(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ := m.handleConfigKey(keyMsg{Type: keyEnter}) m = updated.(Model) if m.configStaged["project.enabled"] != "true" { t.Fatalf("want staged project.enabled=true, got %q", m.configStaged["project.enabled"]) @@ -40,7 +39,7 @@ func TestConfigToggleBoolStagesFlippedValue(t *testing.T) { func TestConfigEnumCycles(t *testing.T) { m := configModel() m.configIndex = 1 // tui.theme - updated, _ := m.handleConfigKey(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ := m.handleConfigKey(keyMsg{Type: keyEnter}) m = updated.(Model) if m.configStaged["tui.theme"] != "light" { t.Fatalf("want staged tui.theme=light (next after dark), got %q", m.configStaged["tui.theme"]) @@ -51,7 +50,7 @@ func TestConfigIntEditCommitsBuffer(t *testing.T) { m := configModel() m.configIndex = 2 // router.generation.max_tokens // Enter edit mode. - updated, _ := m.handleConfigKey(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ := m.handleConfigKey(keyMsg{Type: keyEnter}) m = updated.(Model) if !m.configEditing { t.Fatalf("expected edit mode after enter on INT field") @@ -59,10 +58,10 @@ func TestConfigIntEditCommitsBuffer(t *testing.T) { // Clear the seeded buffer and type a new value. m.configEditBuf = "" for _, r := range "1024" { - updated, _ = m.handleConfigKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + updated, _ = m.handleConfigKey(keyMsg{Type: keyRunes, Runes: []rune{r}}) m = updated.(Model) } - updated, _ = m.handleConfigKey(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ = m.handleConfigKey(keyMsg{Type: keyEnter}) m = updated.(Model) if m.configEditing { t.Fatalf("expected edit mode to end after commit") diff --git a/apps/tui-go/internal/app/demo.go b/apps/tui-go/internal/app/demo.go index cc0cb440..da68ee16 100644 --- a/apps/tui-go/internal/app/demo.go +++ b/apps/tui-go/internal/app/demo.go @@ -256,7 +256,7 @@ func PreviewFrame(kind string, w, h int) string { m.grantIndex = 1 m.overlay = OverlayGrants } - return m.View() + return m.render() } func sampleStats() *protocol.StatsDto { diff --git a/apps/tui-go/internal/app/diff.go b/apps/tui-go/internal/app/diff.go index bf77ec35..906a5460 100644 --- a/apps/tui-go/internal/app/diff.go +++ b/apps/tui-go/internal/app/diff.go @@ -1,10 +1,11 @@ package app import ( + "image/color" "strconv" "strings" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" ) type diffKind int @@ -196,7 +197,7 @@ func (m Model) diffCell(num int, text string, kind diffKind, w int) string { if kind == diffBlank { return lipgloss.NewStyle().Background(t.P.Bg).Render(strings.Repeat(" ", w)) } - var bg, textFg, signFg lipgloss.Color + var bg, textFg, signFg color.Color sign := " " switch kind { case diffAdd: diff --git a/apps/tui-go/internal/app/ideacard.go b/apps/tui-go/internal/app/ideacard.go index 2ae6b450..34ba62e4 100644 --- a/apps/tui-go/internal/app/ideacard.go +++ b/apps/tui-go/internal/app/ideacard.go @@ -3,7 +3,7 @@ package app import ( "strings" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" ) // ideacard.go renders the cross-session idea board (OverlayIdeas): the ideas the router diff --git a/apps/tui-go/internal/app/ideacard_test.go b/apps/tui-go/internal/app/ideacard_test.go index 7a7d2dc1..101d40bd 100644 --- a/apps/tui-go/internal/app/ideacard_test.go +++ b/apps/tui-go/internal/app/ideacard_test.go @@ -5,7 +5,6 @@ import ( "strings" "testing" - tea "github.com/charmbracelet/bubbletea" "github.com/correx/tui-go/internal/protocol" "github.com/correx/tui-go/internal/ws" ) @@ -43,7 +42,7 @@ func TestIdeaRemoveSendsDiscardAndDropsRow(t *testing.T) { m, client := ideaModel() _ = client.Drain() // drop the ListIdeas frame from openIdeas - updated, _ := m.handleOverlayKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")}) + updated, _ := m.handleOverlayKey(keyMsg{Type: keyRunes, Runes: []rune("x")}) m = updated.(Model) if len(m.ideas) != 1 || m.ideas[0].IdeaID != "i2" { diff --git a/apps/tui-go/internal/app/key.go b/apps/tui-go/internal/app/key.go new file mode 100644 index 00000000..ad65323e --- /dev/null +++ b/apps/tui-go/internal/app/key.go @@ -0,0 +1,123 @@ +package app + +import tea "charm.land/bubbletea/v2" + +// bubbletea v2 collapsed key events into a single (Code rune, Mod KeyMod) form +// and dropped the v1 KeyType enum the handlers were written against. Rather than +// rewrite ~190 key-match sites, this shim adapts a v2 key press into the same +// v1-shaped struct (Type + Runes + modifier flags), so all the matching logic +// downstream stays untouched. The only place that ever touches the raw v2 key +// is toKeyMsg below, called once at the Update boundary. + +type keyType int + +const ( + keyRunes keyType = iota + keyEnter + keyUp + keyDown + keyLeft + keyRight + keySpace + keyEsc + keyBackspace + keyTab + keyPgUp + keyPgDown + keyCtrlA + keyCtrlC + keyCtrlD + keyCtrlJ + keyCtrlR + keyCtrlU + keyCtrlX + keyCtrlUp + keyCtrlDown + keyOther +) + +// keyMsg is the v1-shaped key event the handlers consume: a Type plus the typed +// Runes and modifier flags. Shift is newly meaningful under v2's kitty key +// disambiguation — it powers Shift+Enter newlines (see handleInsertKey). +type keyMsg struct { + Type keyType + Runes []rune + Alt bool + Shift bool + str string +} + +func (k keyMsg) String() string { return k.str } + +// toKeyMsg converts a bubbletea v2 key press into the shim's v1-shaped form. +func toKeyMsg(p tea.KeyPressMsg) keyMsg { + k := p.Key() + out := keyMsg{ + Alt: k.Mod.Contains(tea.ModAlt), + Shift: k.Mod.Contains(tea.ModShift), + str: k.Keystroke(), + } + ctrl := k.Mod.Contains(tea.ModCtrl) + switch k.Code { + case tea.KeyEnter: + out.Type = keyEnter + case tea.KeyEscape: + out.Type = keyEsc + case tea.KeyTab: + out.Type = keyTab + case tea.KeyBackspace: + out.Type = keyBackspace + case tea.KeySpace: + out.Type = keySpace + out.Runes = []rune{' '} + case tea.KeyUp: + if ctrl { + out.Type = keyCtrlUp + } else { + out.Type = keyUp + } + case tea.KeyDown: + if ctrl { + out.Type = keyCtrlDown + } else { + out.Type = keyDown + } + case tea.KeyLeft: + out.Type = keyLeft + case tea.KeyRight: + out.Type = keyRight + case tea.KeyPgUp: + out.Type = keyPgUp + case tea.KeyPgDown: + out.Type = keyPgDown + default: + if ctrl { + switch k.Code { + case 'a': + out.Type = keyCtrlA + case 'c': + out.Type = keyCtrlC + case 'd': + out.Type = keyCtrlD + case 'j': + out.Type = keyCtrlJ + case 'r': + out.Type = keyCtrlR + case 'u': + out.Type = keyCtrlU + case 'x': + out.Type = keyCtrlX + default: + out.Type = keyOther + } + return out + } + if k.Text != "" { + out.Type = keyRunes + out.Runes = []rune(k.Text) + } else { + out.Type = keyOther + } + } + return out +} diff --git a/apps/tui-go/internal/app/overlay_composite_test.go b/apps/tui-go/internal/app/overlay_composite_test.go index 6b9a687a..11a1b331 100644 --- a/apps/tui-go/internal/app/overlay_composite_test.go +++ b/apps/tui-go/internal/app/overlay_composite_test.go @@ -4,7 +4,7 @@ import ( "strings" "testing" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" "github.com/charmbracelet/x/ansi" ) diff --git a/apps/tui-go/internal/app/overlays.go b/apps/tui-go/internal/app/overlays.go index c648f457..cbe99ef2 100644 --- a/apps/tui-go/internal/app/overlays.go +++ b/apps/tui-go/internal/app/overlays.go @@ -2,10 +2,11 @@ package app import ( "fmt" + "image/color" "sort" "strings" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" "github.com/charmbracelet/x/ansi" ) @@ -21,8 +22,8 @@ import ( func (m Model) center(modal string) string { if m.lastBase == "" { return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, modal, - lipgloss.WithWhitespaceBackground(m.theme.P.BgDeep), - lipgloss.WithWhitespaceForeground(m.theme.P.BgDeep)) + lipgloss.WithWhitespaceStyle(lipgloss.NewStyle(). + Background(m.theme.P.BgDeep).Foreground(m.theme.P.BgDeep))) } if lipgloss.Height(modal) >= m.height && lipgloss.Width(modal) >= m.width { return modal // already a full-screen composite; don't re-dim it @@ -143,7 +144,7 @@ func (m Model) titleLine(text string) string { return lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Bold(true).Render(text) } -func mbg(t Theme, s string, fg lipgloss.Color) string { +func mbg(t Theme, s string, fg color.Color) string { return lipgloss.NewStyle().Foreground(fg).Background(t.P.BgPanel).Render(s) } @@ -1217,7 +1218,7 @@ func shortID(id string) string { return id[:13] + "…" } -func tierColor(t Theme, tier int) lipgloss.Color { +func tierColor(t Theme, tier int) color.Color { switch { case tier >= 3: return t.P.Bad diff --git a/apps/tui-go/internal/app/proposecard.go b/apps/tui-go/internal/app/proposecard.go index cbbe3b6c..c1f71c53 100644 --- a/apps/tui-go/internal/app/proposecard.go +++ b/apps/tui-go/internal/app/proposecard.go @@ -3,8 +3,8 @@ package app import ( "strings" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "github.com/correx/tui-go/internal/protocol" ) @@ -24,7 +24,7 @@ func (m *Model) proposeInitState() { // --- key handling --- -func (m Model) handleProposeKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { +func (m Model) handleProposeKey(k keyMsg) (tea.Model, tea.Cmd) { s := m.session(m.selectedID) if s == nil || s.Propose == nil { return m, nil @@ -34,15 +34,15 @@ func (m Model) handleProposeKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { } n := len(s.Propose.Candidates) // index n == the custom slot switch k.Type { - case tea.KeyEsc: + case keyEsc: m.proposeDismissed = true - case tea.KeyUp: + case keyUp: m.proposeCursor = clampInt(m.proposeCursor-1, 0, n) - case tea.KeyDown: + case keyDown: m.proposeCursor = clampInt(m.proposeCursor+1, 0, n) - case tea.KeyEnter, tea.KeySpace: + case keyEnter, keySpace: return m.proposeSubmit() - case tea.KeyRunes: + case keyRunes: switch string(k.Runes) { case "k": m.proposeCursor = clampInt(m.proposeCursor-1, 0, n) @@ -56,17 +56,17 @@ func (m Model) handleProposeKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } -func (m Model) handleProposeTyping(k tea.KeyMsg) (tea.Model, tea.Cmd) { +func (m Model) handleProposeTyping(k keyMsg) (tea.Model, tea.Cmd) { switch k.Type { - case tea.KeyEsc: + case keyEsc: m.proposeTyping = false - case tea.KeyEnter: + case keyEnter: return m.proposeSubmit() // commit the custom answer and send it as chat - case tea.KeyBackspace: + case keyBackspace: if len(m.proposeText) > 0 { m.proposeText = m.proposeText[:len(m.proposeText)-1] } - case tea.KeyRunes, tea.KeySpace: + case keyRunes, keySpace: m.proposeText += string(k.Runes) } return m, nil diff --git a/apps/tui-go/internal/app/proposecard_test.go b/apps/tui-go/internal/app/proposecard_test.go index cd8d9f7b..7890e415 100644 --- a/apps/tui-go/internal/app/proposecard_test.go +++ b/apps/tui-go/internal/app/proposecard_test.go @@ -5,7 +5,6 @@ import ( "strings" "testing" - tea "github.com/charmbracelet/bubbletea" "github.com/correx/tui-go/internal/protocol" "github.com/correx/tui-go/internal/ws" ) @@ -47,8 +46,8 @@ func TestProposeEntersStateAndRenders(t *testing.T) { func TestProposePickLaunchesChosenWorkflow(t *testing.T) { m, client := proposeModel() // move cursor to the second candidate (role_pipeline) and launch it - m = applyProposeKey(m, tea.KeyMsg{Type: tea.KeyDown}) - m = applyProposeKey(m, tea.KeyMsg{Type: tea.KeyEnter}) + m = applyProposeKey(m, keyMsg{Type: keyDown}) + m = applyProposeKey(m, keyMsg{Type: keyEnter}) if s := m.session("s1"); s == nil || s.Propose != nil { t.Fatalf("expected proposal cleared after launch") @@ -67,7 +66,7 @@ func TestProposePickLaunchesChosenWorkflow(t *testing.T) { func TestProposeFirstCandidateIsDefault(t *testing.T) { m, client := proposeModel() - m = applyProposeKey(m, tea.KeyMsg{Type: tea.KeyEnter}) // no nav → first candidate + m = applyProposeKey(m, keyMsg{Type: keyEnter}) // no nav → first candidate wf, _ := decodeStart(t, client) if wf != "research" { t.Fatalf("expected first candidate launched by default, got %q", wf) @@ -76,14 +75,14 @@ func TestProposeFirstCandidateIsDefault(t *testing.T) { func TestProposeCustomAnswerContinuesChat(t *testing.T) { m, client := proposeModel() - m = applyProposeKey(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("e")}) + m = applyProposeKey(m, keyMsg{Type: keyRunes, Runes: []rune("e")}) if !m.proposeTyping { t.Fatalf("expected typing mode after 'e'") } for _, r := range "do it manually" { - m = applyProposeKey(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + m = applyProposeKey(m, keyMsg{Type: keyRunes, Runes: []rune{r}}) } - m = applyProposeKey(m, tea.KeyMsg{Type: tea.KeyEnter}) + m = applyProposeKey(m, keyMsg{Type: keyEnter}) if s := m.session("s1"); s == nil || s.Propose != nil { t.Fatalf("expected proposal cleared after custom answer") @@ -109,7 +108,7 @@ func TestProposeCustomAnswerContinuesChat(t *testing.T) { func TestProposeEscDismisses(t *testing.T) { m, _ := proposeModel() - m = applyProposeKey(m, tea.KeyMsg{Type: tea.KeyEsc}) + m = applyProposeKey(m, keyMsg{Type: keyEsc}) if m.displayState() != StateInSession { t.Fatalf("expected StateInSession after esc, got %v", m.displayState()) } @@ -118,7 +117,7 @@ func TestProposeEscDismisses(t *testing.T) { } } -func applyProposeKey(m Model, k tea.KeyMsg) Model { +func applyProposeKey(m Model, k keyMsg) Model { updated, _ := m.handleProposeKey(k) return updated.(Model) } diff --git a/apps/tui-go/internal/app/render_matrix_test.go b/apps/tui-go/internal/app/render_matrix_test.go index 2089d57e..4cbf4dc2 100644 --- a/apps/tui-go/internal/app/render_matrix_test.go +++ b/apps/tui-go/internal/app/render_matrix_test.go @@ -80,7 +80,7 @@ func renderSurface(m Model, s matrixSurface) string { var raw string switch s { case surfaceView: - raw = m.View() + raw = m.render() case surfaceEvents: // Render the event-stream rows at full panel width so a row's detail isn't // clipped at the slim right-panel edge (the production builder, just wide). diff --git a/apps/tui-go/internal/app/sessions_overlay.go b/apps/tui-go/internal/app/sessions_overlay.go index bb07ece6..4963ad1b 100644 --- a/apps/tui-go/internal/app/sessions_overlay.go +++ b/apps/tui-go/internal/app/sessions_overlay.go @@ -7,8 +7,8 @@ import ( "strings" "time" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" ) // SessionSummary is one recent session from GET /sessions. Mirrors the server's @@ -107,19 +107,19 @@ func (m *Model) openSessions() tea.Cmd { // handleSessionsKey owns every key while the session browser is open: navigate // the roster, esc closes, enter resumes the selected session. -func (m Model) handleSessionsKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { +func (m Model) handleSessionsKey(k keyMsg) (tea.Model, tea.Cmd) { switch { - case k.Type == tea.KeyEsc || runeIs(k, "R"): + case k.Type == keyEsc || runeIs(k, "R"): m.overlay = OverlayNone - case k.Type == tea.KeyUp || runeIs(k, "k"): + case k.Type == keyUp || runeIs(k, "k"): if m.sessionListIndex > 0 { m.sessionListIndex-- } - case k.Type == tea.KeyDown || runeIs(k, "j"): + case k.Type == keyDown || runeIs(k, "j"): if m.sessionListIndex < len(m.sessionList)-1 { m.sessionListIndex++ } - case k.Type == tea.KeyEnter: + case k.Type == keyEnter: if m.sessionListIndex >= 0 && m.sessionListIndex < len(m.sessionList) { return m.openSelectedSession() } diff --git a/apps/tui-go/internal/app/sessions_overlay_test.go b/apps/tui-go/internal/app/sessions_overlay_test.go index 83709458..00e5023d 100644 --- a/apps/tui-go/internal/app/sessions_overlay_test.go +++ b/apps/tui-go/internal/app/sessions_overlay_test.go @@ -3,8 +3,6 @@ package app import ( "strings" "testing" - - tea "github.com/charmbracelet/bubbletea" ) func sessionsModel() Model { @@ -25,7 +23,7 @@ func sampleSummaries() []SessionSummary { func TestSessionsOverlayToggle(t *testing.T) { m := sessionsModel() // `R` opens the browser from the idle list. - updated, _ := m.handleNormalKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("R")}) + updated, _ := m.handleNormalKey(keyMsg{Type: keyRunes, Runes: []rune("R")}) m = updated.(Model) if m.overlay != OverlaySessions { t.Fatalf("expected OverlaySessions after R, got %d", m.overlay) @@ -34,7 +32,7 @@ func TestSessionsOverlayToggle(t *testing.T) { t.Fatalf("expected loading state right after open") } // `esc` closes it (routed through the overlay key handler). - updated, _ = m.handleOverlayKey(tea.KeyMsg{Type: tea.KeyEsc}) + updated, _ = m.handleOverlayKey(keyMsg{Type: keyEsc}) m = updated.(Model) if m.overlay != OverlayNone { t.Fatalf("expected overlay closed after esc, got %d", m.overlay) @@ -66,25 +64,25 @@ func TestSessionsNavigationBoundsChecked(t *testing.T) { m.overlay = OverlaySessions m.sessionList = sampleSummaries() // Up at the top is a no-op (clamped at 0). - updated, _ := m.handleSessionsKey(tea.KeyMsg{Type: tea.KeyUp}) + updated, _ := m.handleSessionsKey(keyMsg{Type: keyUp}) m = updated.(Model) if m.sessionListIndex != 0 { t.Fatalf("expected index clamped to 0 at top, got %d", m.sessionListIndex) } // Down moves to row 1. - updated, _ = m.handleSessionsKey(tea.KeyMsg{Type: tea.KeyDown}) + updated, _ = m.handleSessionsKey(keyMsg{Type: keyDown}) m = updated.(Model) if m.sessionListIndex != 1 { t.Fatalf("expected index 1 after down, got %d", m.sessionListIndex) } // Down again is clamped at the last row. - updated, _ = m.handleSessionsKey(tea.KeyMsg{Type: tea.KeyDown}) + updated, _ = m.handleSessionsKey(keyMsg{Type: keyDown}) m = updated.(Model) if m.sessionListIndex != 1 { t.Fatalf("expected index clamped to 1 at bottom, got %d", m.sessionListIndex) } // `j` is the vim-style alias for down (still clamped here). - updated, _ = m.handleSessionsKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")}) + updated, _ = m.handleSessionsKey(keyMsg{Type: keyRunes, Runes: []rune("j")}) m = updated.(Model) if m.sessionListIndex != 1 { t.Fatalf("expected j clamped at bottom, got %d", m.sessionListIndex) @@ -96,7 +94,7 @@ func TestSessionsEnterFocusesAndEmitsResume(t *testing.T) { m.overlay = OverlaySessions m.sessionList = sampleSummaries() m.sessionListIndex = 1 // the FAILED healthcheck row - updated, cmd := m.handleSessionsKey(tea.KeyMsg{Type: tea.KeyEnter}) + updated, cmd := m.handleSessionsKey(keyMsg{Type: keyEnter}) m = updated.(Model) if m.overlay != OverlayNone { t.Fatalf("expected overlay closed after resume, got %d", m.overlay) diff --git a/apps/tui-go/internal/app/slash_test.go b/apps/tui-go/internal/app/slash_test.go index fa7078e1..271346ec 100644 --- a/apps/tui-go/internal/app/slash_test.go +++ b/apps/tui-go/internal/app/slash_test.go @@ -2,8 +2,6 @@ package app import ( "testing" - - tea "github.com/charmbracelet/bubbletea" ) // `/` as the first char of a chat line opens the command palette inline (opencode-style), @@ -14,7 +12,7 @@ func TestSlashOpensPaletteOnEmptyLine(t *testing.T) { m.inputMode = ModeRouter m.inputBuffer = "" - next, _ := m.handleInsertKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}) + next, _ := m.handleInsertKey(keyMsg{Type: keyRunes, Runes: []rune("/")}) nm := next.(Model) if nm.overlay != OverlayPalette { @@ -33,7 +31,7 @@ func TestSlashLiteralMidLine(t *testing.T) { m.inputBuffer = "path" m.inputCursor = len("path") - next, _ := m.handleInsertKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}) + next, _ := m.handleInsertKey(keyMsg{Type: keyRunes, Runes: []rune("/")}) nm := next.(Model) if nm.overlay != OverlayNone { diff --git a/apps/tui-go/internal/app/theme.go b/apps/tui-go/internal/app/theme.go index 914d26bd..150cdeec 100644 --- a/apps/tui-go/internal/app/theme.go +++ b/apps/tui-go/internal/app/theme.go @@ -1,36 +1,40 @@ package app -import "github.com/charmbracelet/lipgloss" +import ( + "image/color" + + "charm.land/lipgloss/v2" +) // Palette is the "Soft" chrome from docs/visual/ref with the blue accent: // rounded borders, opaque dark panels, generous spacing. type Palette struct { - Bg lipgloss.Color // panel/background fill - BgDeep lipgloss.Color // outermost backdrop (slightly darker) - BgPanel lipgloss.Color // inside-panel fill - Sel lipgloss.Color // selection row background - Border lipgloss.Color // idle panel border - BorderHi lipgloss.Color // active panel border - Fg lipgloss.Color // primary text - FgStrong lipgloss.Color // headings / emphasis - Dim lipgloss.Color // labels, secondary text - Faint lipgloss.Color // tertiary, hints - Accent lipgloss.Color // blue accent - Accent2 lipgloss.Color // secondary accent + Bg color.Color // panel/background fill + BgDeep color.Color // outermost backdrop (slightly darker) + BgPanel color.Color // inside-panel fill + Sel color.Color // selection row background + Border color.Color // idle panel border + BorderHi color.Color // active panel border + Fg color.Color // primary text + FgStrong color.Color // headings / emphasis + Dim color.Color // labels, secondary text + Faint color.Color // tertiary, hints + Accent color.Color // blue accent + Accent2 color.Color // secondary accent - OK lipgloss.Color - Warn lipgloss.Color - Bad lipgloss.Color + OK color.Color + Warn color.Color + Bad color.Color - DiffAdd lipgloss.Color // added-line cell background - DiffDel lipgloss.Color // removed-line cell background + DiffAdd color.Color // added-line cell background + DiffDel color.Color // removed-line cell background - CatLifecycle lipgloss.Color - CatContext lipgloss.Color - CatInference lipgloss.Color - CatTool lipgloss.Color - CatDomain lipgloss.Color - CatApproval lipgloss.Color + CatLifecycle color.Color + CatContext color.Color + CatInference color.Color + CatTool color.Color + CatDomain color.Color + CatApproval color.Color } // SoftBlue mirrors the reference Soft theme (#14161a bg, #ced3da fg) with the @@ -91,7 +95,7 @@ type Theme struct { // NewTheme builds the style set for a palette. func NewTheme(p Palette) Theme { base := lipgloss.NewStyle().Background(p.Bg).Foreground(p.Fg) - roundBase := func(border lipgloss.Color) lipgloss.Style { + roundBase := func(border color.Color) lipgloss.Style { return lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(border). @@ -128,7 +132,7 @@ func NewTheme(p Palette) Theme { } // categoryColor maps an event category label to its accent color. -func (t Theme) categoryColor(cat string) lipgloss.Color { +func (t Theme) categoryColor(cat string) color.Color { switch cat { case "Lifecycle": return t.P.CatLifecycle diff --git a/apps/tui-go/internal/app/update.go b/apps/tui-go/internal/app/update.go index 61d5b8af..58273bf0 100644 --- a/apps/tui-go/internal/app/update.go +++ b/apps/tui-go/internal/app/update.go @@ -6,8 +6,8 @@ import ( "strings" "time" + tea "charm.land/bubbletea/v2" osc52 "github.com/aymanbagabas/go-osc52/v2" - tea "github.com/charmbracelet/bubbletea" "github.com/correx/tui-go/internal/protocol" "github.com/correx/tui-go/internal/ws" ) @@ -113,8 +113,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil - case tea.KeyMsg: - next, cmd := m.handleKey(msg) + case tea.KeyPressMsg: + next, cmd := m.handleKey(toKeyMsg(msg)) // A keypress may have entered a typing/active state (insert mode, steering, …); // kick the tick so the caret/spinner animates again. if nm, ok := next.(Model); ok { @@ -175,12 +175,12 @@ func (m *Model) applyServerPhased(msg protocol.ServerMessage) { // --- key handling --- -func (m Model) handleKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { +func (m Model) handleKey(k keyMsg) (tea.Model, tea.Cmd) { debugLog("KEY type=%v runes=%q alt=%v | ds=%s input=%s edit=%s overlay=%d steering=%v steerBuf=%q dismissed=%v entered=%v sel=%s", k.Type, string(k.Runes), k.Alt, m.displayState(), m.inputMode, m.editMode, m.overlay, m.steering, m.steerBuffer, m.approvalDismissed, m.sessionEntered, m.selectedID) // Ctrl+C is a universal hard-quit safety; everything else is bare-key modal. - if k.Type == tea.KeyCtrlC { + if k.Type == keyCtrlC { m.quitting = true return m, tea.Quit } @@ -198,17 +198,17 @@ func (m Model) handleKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { // back through your sent messages without leaving the input. ctrl+↑ older, ctrl+↓ newer. if m.displayState() == StateInSession { switch k.Type { - case tea.KeyCtrlUp: + case keyCtrlUp: m.transcriptNavUser(-1) return m, nil - case tea.KeyCtrlDown: + case keyCtrlDown: m.transcriptNavUser(1) return m, nil } } // On the idle launcher, Tab cycles the launch target (chat → workflows → chat) in any // edit mode, so it works whether or not the input is focused. - if m.displayState() == StateIdle && k.Type == tea.KeyTab { + if m.displayState() == StateIdle && k.Type == keyTab { m.cycleLauncherWf() return m, nil } @@ -219,7 +219,7 @@ func (m Model) handleKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { } // handleNormalKey processes vim-style bare-key commands. -func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { +func (m Model) handleNormalKey(k keyMsg) (tea.Model, tea.Cmd) { ds := m.displayState() // The approval band owns its keys: single-key y/n/e for low tiers, an arm→confirm // gate for T3+, and ↑/↓ to walk the pending queue. Handling it up front keeps the @@ -228,15 +228,15 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { return m.handleApprovalKey(k) } switch k.Type { - case tea.KeyUp: + case keyUp: m.navUp() return m, nil - case tea.KeyDown: + case keyDown: m.navDown() return m, nil - case tea.KeyEnter: + case keyEnter: return m.normalEnter() - case tea.KeyEsc: + case keyEsc: // In-session, esc first drops a transcript selection or output scroll (back to // tail-follow); otherwise it clears the session-list filter. if m.transcriptSel >= 0 { @@ -249,26 +249,26 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { } m.filter = "" return m, nil - case tea.KeyPgUp: + case keyPgUp: m.scrollOutput(m.outputViewportPage()) return m, nil - case tea.KeyPgDown: + case keyPgDown: m.scrollOutput(-m.outputViewportPage()) return m, nil - case tea.KeyCtrlU: + case keyCtrlU: m.scrollOutput(m.outputViewportPage() / 2) return m, nil - case tea.KeyCtrlD: + case keyCtrlD: m.scrollOutput(-m.outputViewportPage() / 2) return m, nil - case tea.KeyCtrlX: + case keyCtrlX: if m.currentDiff() != "" { m.overlay = OverlayDiff m.diffScrollOffset = 0 } return m, nil } - if k.Type != tea.KeyRunes { + if k.Type != keyRunes { return m, nil } switch string(k.Runes) { @@ -350,18 +350,18 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { // handleApprovalKey owns the approval band's bare keys. Low tiers (T0–T2) act on a // single keypress; a T3+ approve arms first and only a second confirming key sends // it (any other key disarms). ↑/↓ walk the pending queue without resolving anything. -func (m Model) handleApprovalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { +func (m Model) handleApprovalKey(k keyMsg) (tea.Model, tea.Cmd) { s := m.session(m.selectedID) if s == nil || s.Pending == nil { return m, nil } // Queue navigation never resolves a gate; it just changes which one is shown. switch { - case k.Type == tea.KeyUp || runeIs(k, "k"): + case k.Type == keyUp || runeIs(k, "k"): s.navApproval(-1) m.approvalArmed = false return m, nil - case k.Type == tea.KeyDown || runeIs(k, "j"): + case k.Type == keyDown || runeIs(k, "j"): s.navApproval(1) m.approvalArmed = false return m, nil @@ -369,7 +369,7 @@ func (m Model) handleApprovalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { // Approve: y / a / enter / ^a. For a high tier this arms the confirm instead of // sending; the same key pressed again (while armed) confirms. - approveKey := k.Type == tea.KeyEnter || k.Type == tea.KeyCtrlA || + approveKey := k.Type == keyEnter || k.Type == keyCtrlA || runeIs(k, "y") || runeIs(k, "a") if approveKey { if s.Pending.HighTier() && !m.approvalArmed { @@ -383,7 +383,7 @@ func (m Model) handleApprovalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { m.approvalArmed = false switch { - case k.Type == tea.KeyCtrlR || runeIs(k, "n") || runeIs(k, "r"): + case k.Type == keyCtrlR || runeIs(k, "n") || runeIs(k, "r"): return m.decide("REJECT") case runeIs(k, "e") || runeIs(k, "s"): // Steer/edit flows into the existing steering-note input path. @@ -396,10 +396,10 @@ func (m Model) handleApprovalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { m.sessionEntered = false m.approvalDismissed = false return m, nil - case k.Type == tea.KeyEsc: + case k.Type == keyEsc: m.approvalDismissed = true return m, nil - case k.Type == tea.KeyCtrlX: + case k.Type == keyCtrlX: if m.currentDiff() != "" { m.overlay = OverlayDiff m.diffScrollOffset = 0 @@ -414,26 +414,26 @@ func (m Model) handleApprovalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { } // handleInsertKey processes typing (chat/filter/steer note). -func (m Model) handleInsertKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { +func (m Model) handleInsertKey(k keyMsg) (tea.Model, tea.Cmd) { if m.steering { switch k.Type { - case tea.KeyEsc: + case keyEsc: m.steering = false m.editMode = ModeNormal - case tea.KeyEnter: + case keyEnter: return m.submitApproval() - case tea.KeyBackspace: + case keyBackspace: if n := len(m.steerBuffer); n > 0 { m.steerBuffer = m.steerBuffer[:n-1] } - case tea.KeyRunes, tea.KeySpace: + case keyRunes, keySpace: m.steerBuffer += string(k.Runes) } return m, nil } switch k.Type { - case tea.KeyEsc: + case keyEsc: m.editMode = ModeNormal if m.inputMode == ModeFilter { m.inputMode = ModeRouter @@ -444,45 +444,46 @@ func (m Model) handleInsertKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { m.clearInput() m.inputMode = ModeRouter } - case tea.KeyEnter: - // Alt+Enter inserts a newline; a bare Enter submits. (Shift+Enter is not - // distinguishable from Enter in this terminal stack — see Ctrl+J below.) - if k.Alt && m.inputMode != ModeFilter { + case keyEnter: + // Shift+Enter (and Alt+Enter) insert a newline; a bare Enter submits. Shift+Enter + // is now distinguishable thanks to bubbletea v2's kitty keyboard disambiguation; + // Ctrl+J below stays as a fallback for terminals without kitty support. + if (k.Shift || k.Alt) && m.inputMode != ModeFilter { m.appendRunes("\n") return m, nil } return m.submit() - case tea.KeyCtrlJ: + case keyCtrlJ: // Reliable "newline without sending" (Ctrl+J is the literal LF key) for the // single-line filter excluded. if m.inputMode != ModeFilter { m.appendRunes("\n") } return m, nil - case tea.KeyBackspace: + case keyBackspace: m.backspace() m.syncFilter() - case tea.KeyLeft: + case keyLeft: if m.inputCursor > 0 { m.inputCursor-- } - case tea.KeyRight: + case keyRight: if m.inputCursor < len(m.inputBuffer) { m.inputCursor++ } - case tea.KeyUp: + case keyUp: if m.inputMode == ModeFilter { m.listNav(-1) } else { m.historyPrev() } - case tea.KeyDown: + case keyDown: if m.inputMode == ModeFilter { m.listNav(1) } else { m.historyNext() } - case tea.KeyRunes, tea.KeySpace: + case keyRunes, keySpace: // opencode-style slash commands: `/` as the first char of a chat line opens the // command palette inline instead of typing a literal slash. Mid-line `/` is literal. if m.inputMode == ModeRouter && m.inputBuffer == "" && string(k.Runes) == "/" { @@ -627,7 +628,7 @@ func (m Model) autoApprove() (tea.Model, tea.Cmd) { return m, nil } -func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { +func (m Model) handleOverlayKey(k keyMsg) (tea.Model, tea.Cmd) { // Config overlay owns all its keys (esc cancels an in-progress edit rather than closing). if m.overlay == OverlayConfig { return m.handleConfigKey(k) @@ -637,7 +638,7 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { if m.overlay == OverlaySessions { return m.handleSessionsKey(k) } - if k.Type == tea.KeyEsc { + if k.Type == keyEsc { // In the event inspector, esc first stops an in-progress filter edit (keeping the // query) rather than closing the overlay. if m.overlay == OverlayEventInspector && m.eventFilterTyping { @@ -656,37 +657,37 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { // full body, ^u/^d by half, g/G to the ends. All clamp to the diff bounds. page := m.diffBodyHeight() switch { - case k.Type == tea.KeyUp || runeIs(k, "k"): + case k.Type == keyUp || runeIs(k, "k"): m.scrollDiff(-1) - case k.Type == tea.KeyDown || runeIs(k, "j"): + case k.Type == keyDown || runeIs(k, "j"): m.scrollDiff(1) - case k.Type == tea.KeyPgUp: + case k.Type == keyPgUp: m.scrollDiff(-page) - case k.Type == tea.KeyPgDown: + case k.Type == keyPgDown: m.scrollDiff(page) - case k.Type == tea.KeyCtrlU: + case k.Type == keyCtrlU: m.scrollDiff(-page / 2) - case k.Type == tea.KeyCtrlD: + case k.Type == keyCtrlD: m.scrollDiff(page / 2) case runeIs(k, "g"): m.diffScrollOffset = 0 case runeIs(k, "G"): m.diffScrollOffset = m.diffMaxScroll() - case k.Type == tea.KeyCtrlX: + case k.Type == keyCtrlX: m.overlay = OverlayNone } case OverlayEventInspector: evs := m.currentEvents() if m.eventFilterTyping { switch { - case k.Type == tea.KeyEnter: + case k.Type == keyEnter: m.eventFilterTyping = false - case k.Type == tea.KeyBackspace: + case k.Type == keyBackspace: if n := len(m.eventFilter); n > 0 { m.eventFilter = m.eventFilter[:n-1] m.overlayEventIdx = 0 } - case k.Type == tea.KeyRunes || k.Type == tea.KeySpace: + case k.Type == keyRunes || k.Type == keySpace: m.eventFilter += string(k.Runes) m.overlayEventIdx = 0 } @@ -698,11 +699,11 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { case runeIs(k, "c"): m.eventFilter = "" m.overlayEventIdx = 0 - case k.Type == tea.KeyUp || runeIs(k, "k"): + case k.Type == keyUp || runeIs(k, "k"): if m.overlayEventIdx > 0 { m.overlayEventIdx-- } - case k.Type == tea.KeyDown || runeIs(k, "j"): + case k.Type == keyDown || runeIs(k, "j"): if m.overlayEventIdx < len(evs)-1 { m.overlayEventIdx++ } @@ -712,17 +713,17 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { // dismisses it (esc handled above), keeping the quick-glance feel. page := m.modalBodyRows() switch { - case k.Type == tea.KeyUp || runeIs(k, "k"): + case k.Type == keyUp || runeIs(k, "k"): m.scrollModal(-1) - case k.Type == tea.KeyDown || runeIs(k, "j"): + case k.Type == keyDown || runeIs(k, "j"): m.scrollModal(1) - case k.Type == tea.KeyPgUp: + case k.Type == keyPgUp: m.scrollModal(-page) - case k.Type == tea.KeyPgDown: + case k.Type == keyPgDown: m.scrollModal(page) - case k.Type == tea.KeyCtrlU: + case k.Type == keyCtrlU: m.scrollModal(-page / 2) - case k.Type == tea.KeyCtrlD: + case k.Type == keyCtrlD: m.scrollModal(page / 2) case runeIs(k, "g"): m.modalScroll = 0 @@ -737,23 +738,23 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { } case OverlayArtifacts: switch { - case k.Type == tea.KeyUp || runeIs(k, "k"): + case k.Type == keyUp || runeIs(k, "k"): if m.artifactsIndex > 0 { m.artifactsIndex-- m.artifactScroll = 0 } - case k.Type == tea.KeyDown || runeIs(k, "j"): + case k.Type == keyDown || runeIs(k, "j"): if m.artifactsIndex < len(m.artifacts)-1 { m.artifactsIndex++ m.artifactScroll = 0 } - case k.Type == tea.KeyPgUp: + case k.Type == keyPgUp: m.scrollArtifact(-m.artifactContentBodyH()) - case k.Type == tea.KeyPgDown: + case k.Type == keyPgDown: m.scrollArtifact(m.artifactContentBodyH()) - case k.Type == tea.KeyCtrlU: + case k.Type == keyCtrlU: m.scrollArtifact(-m.artifactContentBodyH() / 2) - case k.Type == tea.KeyCtrlD: + case k.Type == keyCtrlD: m.scrollArtifact(m.artifactContentBodyH() / 2) case runeIs(k, "g"): m.artifactScroll = 0 @@ -764,15 +765,15 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { } case OverlayModels: switch { - case k.Type == tea.KeyUp || runeIs(k, "k"): + case k.Type == keyUp || runeIs(k, "k"): if m.modelsIndex > 0 { m.modelsIndex-- } - case k.Type == tea.KeyDown || runeIs(k, "j"): + case k.Type == keyDown || runeIs(k, "j"): if m.modelsIndex < len(m.availableModels)-1 { m.modelsIndex++ } - case k.Type == tea.KeyEnter: + case k.Type == keyEnter: if m.modelsIndex >= 0 && m.modelsIndex < len(m.availableModels) { m.client.Send(protocol.SwapModel(m.availableModels[m.modelsIndex])) m.overlay = OverlayNone @@ -786,17 +787,17 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { case OverlayStats: page := m.modalBodyRows() switch { - case k.Type == tea.KeyUp || runeIs(k, "k"): + case k.Type == keyUp || runeIs(k, "k"): m.scrollModal(-1) - case k.Type == tea.KeyDown || runeIs(k, "j"): + case k.Type == keyDown || runeIs(k, "j"): m.scrollModal(1) - case k.Type == tea.KeyPgUp: + case k.Type == keyPgUp: m.scrollModal(-page) - case k.Type == tea.KeyPgDown: + case k.Type == keyPgDown: m.scrollModal(page) - case k.Type == tea.KeyCtrlU: + case k.Type == keyCtrlU: m.scrollModal(-page / 2) - case k.Type == tea.KeyCtrlD: + case k.Type == keyCtrlD: m.scrollModal(page / 2) case runeIs(k, "g"): m.modalScroll = 0 @@ -807,11 +808,11 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { } case OverlayIdeas: switch { - case k.Type == tea.KeyUp || runeIs(k, "k"): + case k.Type == keyUp || runeIs(k, "k"): if m.ideasIndex > 0 { m.ideasIndex-- } - case k.Type == tea.KeyDown || runeIs(k, "j"): + case k.Type == keyDown || runeIs(k, "j"): if m.ideasIndex < len(m.ideas)-1 { m.ideasIndex++ } @@ -830,39 +831,39 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { case OverlayFiles: files := m.filteredFiles() switch { - case k.Type == tea.KeyUp: + case k.Type == keyUp: if m.fileIndex > 0 { m.fileIndex-- } - case k.Type == tea.KeyDown: + case k.Type == keyDown: if m.fileIndex < len(files)-1 { m.fileIndex++ } - case k.Type == tea.KeyEnter: + case k.Type == keyEnter: if m.fileIndex >= 0 && m.fileIndex < len(files) { m.insertFileRef(files[m.fileIndex]) } m.overlay = OverlayNone // back to typing (still in insert mode) - case k.Type == tea.KeyBackspace: + case k.Type == keyBackspace: if n := len(m.fileFilter); n > 0 { m.fileFilter = m.fileFilter[:n-1] m.fileIndex = 0 } - case k.Type == tea.KeyRunes || k.Type == tea.KeySpace: + case k.Type == keyRunes || k.Type == keySpace: m.fileFilter += string(k.Runes) m.fileIndex = 0 } case OverlayStatusbar: switch { - case k.Type == tea.KeyUp || runeIs(k, "k"): + case k.Type == keyUp || runeIs(k, "k"): if m.sbIndex > 0 { m.sbIndex-- } - case k.Type == tea.KeyDown || runeIs(k, "j"): + case k.Type == keyDown || runeIs(k, "j"): if m.sbIndex < len(statusSegments)-1 { m.sbIndex++ } - case k.Type == tea.KeySpace || k.Type == tea.KeyEnter || runeIs(k, "x"): + case k.Type == keySpace || k.Type == keyEnter || runeIs(k, "x"): if m.sbIndex >= 0 && m.sbIndex < len(statusSegments) { m.toggleStatusSegment(statusSegments[m.sbIndex].id) } @@ -871,15 +872,15 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { } case OverlayGrants: switch { - case k.Type == tea.KeyUp || runeIs(k, "k"): + case k.Type == keyUp || runeIs(k, "k"): if m.grantIndex > 0 { m.grantIndex-- } - case k.Type == tea.KeyDown || runeIs(k, "j"): + case k.Type == keyDown || runeIs(k, "j"): if m.grantIndex < len(m.grants)-1 { m.grantIndex++ } - case k.Type == tea.KeyEnter || runeIs(k, "x"): + case k.Type == keyEnter || runeIs(k, "x"): if m.grantIndex >= 0 && m.grantIndex < len(m.grants) { g := m.grants[m.grantIndex] m.client.Send(protocol.RevokeGrant(g.GrantID)) @@ -910,14 +911,14 @@ func grantScopeOptions() []grantScopeOption { // handleGrantScopeKey drives the scope picker: ↑/↓ move, s/p/g jump-and-confirm, enter // confirms the highlighted scope. esc (handled upstream) cancels without granting. -func (m Model) handleGrantScopeKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { +func (m Model) handleGrantScopeKey(k keyMsg) (tea.Model, tea.Cmd) { opts := grantScopeOptions() switch { - case k.Type == tea.KeyUp || runeIs(k, "k"): + case k.Type == keyUp || runeIs(k, "k"): if m.grantScopeIndex > 0 { m.grantScopeIndex-- } - case k.Type == tea.KeyDown || runeIs(k, "j"): + case k.Type == keyDown || runeIs(k, "j"): if m.grantScopeIndex < len(opts)-1 { m.grantScopeIndex++ } @@ -930,7 +931,7 @@ func (m Model) handleGrantScopeKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { case runeIs(k, "g"): m.grantScopeIndex = 2 return m.applyGrantScope() - case k.Type == tea.KeyEnter: + case k.Type == keyEnter: return m.applyGrantScope() } return m, nil @@ -1046,31 +1047,31 @@ func (m *Model) openModelsOverlay() { } } -func runeIs(k tea.KeyMsg, s string) bool { - return k.Type == tea.KeyRunes && string(k.Runes) == s +func runeIs(k keyMsg, s string) bool { + return k.Type == keyRunes && string(k.Runes) == s } -func (m Model) handlePaletteKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { +func (m Model) handlePaletteKey(k keyMsg) (tea.Model, tea.Cmd) { cmds := m.filteredPalette() switch k.Type { - case tea.KeyUp: + case keyUp: if m.paletteIndex > 0 { m.paletteIndex-- } - case tea.KeyDown: + case keyDown: if m.paletteIndex < len(cmds)-1 { m.paletteIndex++ } - case tea.KeyEnter: + case keyEnter: if m.paletteIndex >= 0 && m.paletteIndex < len(cmds) { return m.execPalette(cmds[m.paletteIndex].id) } - case tea.KeyBackspace: + case keyBackspace: if n := len(m.paletteFilter); n > 0 { m.paletteFilter = m.paletteFilter[:n-1] m.paletteIndex = 0 } - case tea.KeyRunes, tea.KeySpace: + case keyRunes, keySpace: m.paletteFilter += string(k.Runes) m.paletteIndex = 0 } diff --git a/apps/tui-go/internal/app/view.go b/apps/tui-go/internal/app/view.go index ff7f4a9d..6693c7d5 100644 --- a/apps/tui-go/internal/app/view.go +++ b/apps/tui-go/internal/app/view.go @@ -2,11 +2,13 @@ package app import ( "fmt" + "image/color" "os" "strings" "time" - "github.com/charmbracelet/lipgloss" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "github.com/charmbracelet/x/ansi" ) @@ -28,8 +30,15 @@ const ( // and full-width chrome. func (m Model) narrow() bool { return m.width < narrowWidth } -// View renders the whole frame purely from the model (immediate-mode). -func (m Model) View() string { +// View renders the whole frame purely from the model (immediate-mode). In +// bubbletea v2 the View carries terminal state (alt-screen, keyboard mode), so +// render() builds the string and View() wraps it with the screen settings. +func (m Model) View() tea.View { + return tea.View{Content: m.render(), AltScreen: true} +} + +// render builds the frame string. +func (m Model) render() string { if m.quitting { return "" } @@ -79,7 +88,7 @@ func (m Model) View() string { func (m Model) renderStatus() string { t := m.theme bg := t.P.BgPanel - span := func(s string, fg lipgloss.Color) string { + span := func(s string, fg color.Color) string { return lipgloss.NewStyle().Foreground(fg).Background(bg).Render(s) } nrw := m.narrow() @@ -376,12 +385,12 @@ func (m Model) renderLauncher(w, h int) string { inW = 24 } left := lipgloss.Place(leftW, h, lipgloss.Center, lipgloss.Center, m.launcherInput(inW), - lipgloss.WithWhitespaceBackground(t.P.Bg)) + lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.P.Bg))) if railW == 0 { return left } rail := lipgloss.Place(railW, h, lipgloss.Left, lipgloss.Top, m.launcherRail(railW), - lipgloss.WithWhitespaceBackground(t.P.Bg)) + lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.P.Bg))) return lipgloss.JoinHorizontal(lipgloss.Top, left, rail) } @@ -738,7 +747,7 @@ func (m Model) eventStreamBadge() string { return t.span("○ idle", t.P.Faint) } if s := m.session(m.selectedID); s != nil { - badge := func(fg lipgloss.Color, text string) string { + badge := func(fg color.Color, text string) string { return lipgloss.NewStyle().Foreground(fg).Background(t.P.Bg).Render(text) } switch u := strings.ToUpper(s.Status); { @@ -764,7 +773,7 @@ func shortPath(p string) string { } // fill left-justifies a styled line and pads with bg to width w. -func (m Model) fill(s string, w int, bg lipgloss.Color) string { +func (m Model) fill(s string, w int, bg color.Color) string { return " " + padTo(s, w-1, bg) } @@ -772,7 +781,7 @@ func (m Model) fill(s string, w int, bg lipgloss.Color) string { // so the line never overflows w. The left segment is shrunk first (the right holds // the high-signal status — connection clock / active spinner); the right is clamped // only as a last resort. ANSI-aware so truncation can't slice through escape codes. -func (m Model) justify(left, right string, w int, bg lipgloss.Color) string { +func (m Model) justify(left, right string, w int, bg color.Color) string { avail := w - 2 // leading + trailing space lw := lipgloss.Width(left) rw := lipgloss.Width(right) @@ -798,7 +807,7 @@ func (m Model) justify(left, right string, w int, bg lipgloss.Color) string { // padTo pads (or truncates) a possibly-styled string to visible width w, filling // with the given background so the panel stays opaque. -func padTo(s string, w int, bg lipgloss.Color) string { +func padTo(s string, w int, bg color.Color) string { vw := lipgloss.Width(s) if vw == w { return s @@ -825,7 +834,7 @@ func padRaw(s string, w int) string { return s + strings.Repeat(" ", w-n) } -func statusColor(t Theme, status string) lipgloss.Color { +func statusColor(t Theme, status string) color.Color { u := strings.ToUpper(status) switch { case strings.Contains(u, "FAIL"): @@ -862,7 +871,7 @@ func statusGlyph(t Theme, status string) string { } // span renders text with foreground fg over the panel background. -func (t Theme) span(s string, fg lipgloss.Color) string { +func (t Theme) span(s string, fg color.Color) string { return lipgloss.NewStyle().Foreground(fg).Background(t.P.Bg).Render(s) } diff --git a/apps/tui-go/main.go b/apps/tui-go/main.go index 9d40068b..cf18bddc 100644 --- a/apps/tui-go/main.go +++ b/apps/tui-go/main.go @@ -8,7 +8,7 @@ import ( "fmt" "os" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" "github.com/correx/tui-go/internal/app" "github.com/correx/tui-go/internal/ws" ) @@ -33,7 +33,8 @@ func main() { defer cancel() go client.Run(ctx) - p := tea.NewProgram(app.NewModel(client), tea.WithAltScreen()) + // Alt-screen is requested per-frame via the View's AltScreen field in v2. + p := tea.NewProgram(app.NewModel(client)) if _, err := p.Run(); err != nil { fmt.Fprintln(os.Stderr, "correx tui error:", err) os.Exit(1) From 85af2c67b7ccfa294a512451165c3a83ff5cff86 Mon Sep 17 00:00:00 2001 From: kami Date: Mon, 22 Jun 2026 16:28:22 +0000 Subject: [PATCH 069/107] tui: real terminal cursor in composer + window title (v2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two v2-unlocked polish items now that the TUI is on bubbletea v2: Native cursor — the composer caret was a frame-animated "▏" that forced the redraw loop to keep ticking in insert mode. v2's View.Cursor lets the terminal draw and blink a real cursor, so: - editorLines/launcherInput drop a width-1 marker rune (U+E123, same cell width as "▏") at the caret; View() locates it in the fully-composited frame via splitCursor → (X,Y), swaps it for a space, and sets a blinking CursorBar there. - render() (preview/golden tests) resolves the marker to the static "▏" glyph, so screenshots are unchanged and the marker never leaks into output. - nil cursor in normal mode hides it (vim-correct); gated on overlay==None so an open palette/files modal (which keeps editMode==Insert) doesn't draw a cursor behind it. - animating() no longer forces a redraw in insert mode — the terminal blinks the cursor itself, so an idle insert screen holds still (native text selection survives) and burns no frames. Window title — View.WindowTitle names the terminal tab after the active session, falling back to the model name then "correx". Tests: new cursor_test.go (coord extraction, mode/overlay gating, no marker leak); updated the animating-gate test for the new insert-mode contract. Build, vet, gofmt, full suite green; compose preview shows the static caret with no marker leak. Co-Authored-By: Claude Opus 4.8 --- apps/tui-go/internal/app/cursor_test.go | 83 +++++++++++++++++++ .../tui-go/internal/app/input_history_test.go | 14 +++- apps/tui-go/internal/app/update.go | 5 +- apps/tui-go/internal/app/view.go | 66 +++++++++++++-- 4 files changed, 157 insertions(+), 11 deletions(-) create mode 100644 apps/tui-go/internal/app/cursor_test.go diff --git a/apps/tui-go/internal/app/cursor_test.go b/apps/tui-go/internal/app/cursor_test.go new file mode 100644 index 00000000..a32c4232 --- /dev/null +++ b/apps/tui-go/internal/app/cursor_test.go @@ -0,0 +1,83 @@ +package app + +import ( + "strings" + "testing" +) + +// splitCursor must report the caret's display column/row from a composited frame +// (ANSI ignored) and swap the marker for the replacement glyph. +func TestSplitCursorCoords(t *testing.T) { + // marker sits after 3 visible cells ("a", "b", "c") on the second line. + raw := "first line\n" + "ab\x1b[38;2;1;2;3mc\x1b[m" + cursorMarker + "d" + clean, cur := splitCursor(raw, " ") + if cur == nil { + t.Fatal("expected a cursor when the marker is present") + } + if cur.X != 3 || cur.Y != 1 { + t.Fatalf("cursor at (%d,%d), want (3,1)", cur.X, cur.Y) + } + if strings.Contains(clean, cursorMarker) { + t.Error("marker must be stripped from the returned frame") + } + if strings.Count(clean, " ") == 0 { + t.Error("marker should have been replaced by the repl glyph") + } +} + +// No marker (e.g. normal mode) → no cursor, frame unchanged. +func TestSplitCursorAbsent(t *testing.T) { + raw := "no caret here" + clean, cur := splitCursor(raw, "▏") + if cur != nil { + t.Error("no marker must yield a nil cursor (hidden)") + } + if clean != raw { + t.Error("frame must be unchanged when no marker is present") + } +} + +// The live View hides the cursor in normal mode and shows a real one in insert +// mode; the marker must never leak into the rendered content either way. +func TestViewCursorByMode(t *testing.T) { + m := inSessionModel(120, 40) + + m.editMode = ModeNormal + v := m.View() + if v.Cursor != nil { + t.Error("normal mode must hide the cursor (nil)") + } + if strings.Contains(v.Content, cursorMarker) { + t.Error("content must not contain the raw cursor marker") + } + + m.editMode = ModeInsert + m.inputMode = ModeRouter + m.inputBuffer = "hello" + m.inputCursor = len(m.inputBuffer) + v = m.View() + if v.Cursor == nil { + t.Fatal("insert mode must show a real cursor") + } + if v.Cursor.Y < 0 || v.Cursor.Y >= m.height || v.Cursor.X < 0 { + t.Errorf("cursor (%d,%d) out of frame bounds", v.Cursor.X, v.Cursor.Y) + } + if strings.Contains(v.Content, cursorMarker) { + t.Error("content must not contain the raw cursor marker") + } + + // The static render keeps a visible glyph and also strips the marker. + if strings.Contains(m.render(), cursorMarker) { + t.Error("render() must strip the marker") + } + + // An open overlay keeps editMode==Insert (so closing resumes typing) but the + // composer must not draw a live cursor behind the modal. + m.overlay = OverlayPalette + if v = m.View(); v.Cursor != nil { + t.Error("an open overlay must suppress the composer cursor") + } + if strings.Contains(v.Content, cursorMarker) { + t.Error("content must not contain the marker with an overlay open") + } +} diff --git a/apps/tui-go/internal/app/input_history_test.go b/apps/tui-go/internal/app/input_history_test.go index 3b741567..58800758 100644 --- a/apps/tui-go/internal/app/input_history_test.go +++ b/apps/tui-go/internal/app/input_history_test.go @@ -78,12 +78,22 @@ func TestAnimatingGatesIdleRedraw(t *testing.T) { t.Fatal("idle (normal mode, no active session) must NOT animate — else selection is wiped") } + // Insert mode must NOT force the frame loop anymore: the composer caret is a real + // terminal cursor (View.Cursor) that the terminal blinks itself, so an idle insert + // screen holds still (and native text selection survives). m.editMode = ModeInsert - if !m.animating() { - t.Fatal("insert mode must animate (blinking caret)") + if m.animating() { + t.Fatal("insert mode must NOT animate — the native cursor blinks itself") } m.editMode = ModeNormal + // Other typing surfaces still draw their own carets and keep ticking. + m.steering = true + if !m.animating() { + t.Fatal("steering (drawn caret) must animate") + } + m.steering = false + m.sessions = []Session{{ID: "s1", Active: true}} if !m.animating() { t.Fatal("an active session must animate (spinner)") diff --git a/apps/tui-go/internal/app/update.go b/apps/tui-go/internal/app/update.go index 58273bf0..83e75465 100644 --- a/apps/tui-go/internal/app/update.go +++ b/apps/tui-go/internal/app/update.go @@ -48,7 +48,10 @@ func (m Model) Init() tea.Cmd { // When false, the tick loop stops and the screen holds still — so a native terminal // text selection survives instead of being wiped by the next redraw. func (m Model) animating() bool { - if m.editMode == ModeInsert || m.steering || m.clarTyping || m.proposeTyping || m.configEditing { + // Note: ModeInsert is intentionally NOT here — the composer caret is now a real + // terminal cursor (View.Cursor) that the terminal blinks itself, so insert mode no + // longer needs the frame loop. The other typing surfaces keep their drawn carets. + if m.steering || m.clarTyping || m.proposeTyping || m.configEditing { return true } if m.reconnecting { diff --git a/apps/tui-go/internal/app/view.go b/apps/tui-go/internal/app/view.go index 6693c7d5..39cc70b6 100644 --- a/apps/tui-go/internal/app/view.go +++ b/apps/tui-go/internal/app/view.go @@ -30,15 +30,63 @@ const ( // and full-width chrome. func (m Model) narrow() bool { return m.width < narrowWidth } +// cursorMarker is a private-use rune the composer drops into the caret cell so +// View() can locate it in the fully-composited frame and place a real terminal +// cursor there (see splitCursor). It measures one display column — same as the +// "▏" glyph it stands in for — so the layout is identical whether it resolves to +// a live cursor or the static glyph. +const cursorMarker = "" + // View renders the whole frame purely from the model (immediate-mode). In -// bubbletea v2 the View carries terminal state (alt-screen, keyboard mode), so -// render() builds the string and View() wraps it with the screen settings. +// bubbletea v2 the View carries terminal state, so renderRaw() builds the string +// and View() resolves the caret to a real blinking cursor + sets the window title. func (m Model) View() tea.View { - return tea.View{Content: m.render(), AltScreen: true} + content, cur := splitCursor(m.renderRaw(), " ") + return tea.View{Content: content, AltScreen: true, Cursor: cur, WindowTitle: m.windowTitle()} } -// render builds the frame string. +// render returns the frame as a plain string with the caret drawn as the static +// "▏" glyph — for the preview tool and golden tests, which have no live cursor. func (m Model) render() string { + content, _ := splitCursor(m.renderRaw(), "▏") + return content +} + +// splitCursor finds the caret marker in a composited frame, replaces it with repl +// (a space for the live cursor cell, "▏" for static), and returns the cursor's +// screen position — nil when no marker is present (e.g. vim normal mode), which +// tells bubbletea to hide the cursor. +func splitCursor(raw, repl string) (string, *tea.Cursor) { + idx := strings.Index(raw, cursorMarker) + if idx < 0 { + return raw, nil + } + pre := raw[:idx] + y := strings.Count(pre, "\n") + lineStart := strings.LastIndex(pre, "\n") + 1 // 0 when the marker is on the first line + x := ansi.StringWidth(raw[lineStart:idx]) + clean := strings.Replace(raw, cursorMarker, repl, 1) + cur := tea.NewCursor(x, y) + cur.Shape = tea.CursorBar + return clean, cur +} + +// windowTitle names the terminal window/tab after the active session, falling +// back to the model name and then the bare program name. +func (m Model) windowTitle() string { + if m.displayState() == StateInSession { + if s := m.session(m.selectedID); s != nil && s.Name != "" { + return "correx — " + s.Name + } + } + if m.currentModel != "" { + return "correx — " + m.currentModel + } + return "correx" +} + +// renderRaw builds the frame string (caret rendered as cursorMarker in insert mode). +func (m Model) renderRaw() string { if m.quitting { return "" } @@ -403,8 +451,10 @@ const maxInputLines = 6 func (m Model) editorLines() []string { t := m.theme caret := "" - if m.editMode == ModeInsert && m.caretVisible() { - caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Render("▏") + // Only the focused composer (no modal over it) owns the live cursor; an open + // overlay keeps editMode==Insert but draws its own filter caret instead. + if m.editMode == ModeInsert && m.overlay == OverlayNone { + caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Render(cursorMarker) } buf := m.inputBuffer cur := m.inputCursor @@ -551,8 +601,8 @@ func (m Model) renderInput() string { insert := m.editMode == ModeInsert caret := t.span(" ", t.P.Bg) - if insert && m.caretVisible() { - caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Render("▏") + if insert && m.overlay == OverlayNone { + caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Render(cursorMarker) } var content []string switch { From c2a1e6d76d19d9e10096f0c01607ad7dd3f50442 Mon Sep 17 00:00:00 2001 From: kami Date: Mon, 22 Jun 2026 16:32:07 +0000 Subject: [PATCH 070/107] docs: archive v2/cursor TUI work in RETRO + add QA-tui-v2 live-QA plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the repo's own hygiene + QA rules: record the operator-requested v2 work in RETRO.md (2026-06-22 TUI wave table: d247b19 migration, 85af2c6 cursor/title) and draft docs/qa/QA-tui-v2.md — the kitty-box checklist for the behavioral claims go test can't prove (Shift+Enter newline, the real blinking cursor + its normal-mode/modal gating, window title, and a render-regression sweep across the new v2 layer). Indexed in docs/qa/README.md as the cheapest gate (no model/network/GPU). Co-Authored-By: Claude Opus 4.8 --- RETRO.md | 5 +++++ docs/qa/QA-tui-v2.md | 52 ++++++++++++++++++++++++++++++++++++++++++++ docs/qa/README.md | 2 ++ 3 files changed, 59 insertions(+) create mode 100644 docs/qa/QA-tui-v2.md diff --git a/RETRO.md b/RETRO.md index 8ddb0d3d..61182c7d 100644 --- a/RETRO.md +++ b/RETRO.md @@ -37,8 +37,13 @@ test` green and rendered via `cmd/preview` before commit: | `f14a83c` | Drop the inline tool-*start* rows (they doubled up with the result rows). | | `531910d` | Transcript free-scroll (`PgUp/PgDn`, `^u/^d`, `esc` snaps to tail-follow; clamped via `outputViewport()`/`buildTranscriptRows`, 2 tests) + help overlay (`?`) + event-inspector `/` filter. | | `db27b34` | Page-scroll the `^x` diff/preview/command fullscreen viewer (`↑↓` line, `PgUp/PgDn` page, `^u/^d` half, `g/G` ends; `scrollDiff()` clamp, 2 tests) + advertise it in the modal hint and the `?` help overlay. Closes the §E approval-ergonomics re-audit. | +| `d247b19` | **Bubble Tea v1 → v2** (`charm.land/{bubbletea,lipgloss}/v2`, Go 1.25). A key-handling shim (`key.go`: v2 `KeyPressMsg` → v1-shaped `keyMsg` at the single `Update` boundary) keeps ~190 match sites untouched. Other v2 breaks handled: `lipgloss.Color` is now a func returning `color.Color` (retyped fields/params); `View()` returns `tea.View` (`render()` builds the string, `View()` carries `AltScreen` — alt-screen is a per-frame field now, not a program option); `WithWhitespaceStyle` replaced `WithWhitespace{Background,Foreground}`; preview dropped `SetColorProfile` (v2 = truecolor by default). Enables literal **Shift+Enter** newline via v2 kitty disambiguation (`Ctrl+J`/`Alt+Enter` kept as non-kitty fallback). Live-QA: `docs/qa/QA-tui-v2.md`. | +| `85af2c6` | **Real terminal cursor in composer + window title.** `View.Cursor` replaces the frame-animated `▏`: a width-1 marker rune (U+E123) is located in the fully-composited frame → blinking `CursorBar` at `(X,Y)`; static `render()` resolves it back to `▏` so screenshots/goldens are unchanged. Nil in normal mode (hidden, vim-correct), suppressed when a modal is open (which keeps `editMode==Insert`). `animating()` no longer ticks in insert mode — the terminal blinks the cursor itself, so an idle insert screen holds still (native text selection survives). `View.WindowTitle` = active session/model. New `cursor_test.go` (3) + updated animating-gate test. | These are mostly deterministic render/UX changes (verifiable by `go test` + `cmd/preview`). +The v2 migration + native cursor are unit-verified the same way, but their *behavioral* wins +(Shift+Enter, the real blinking cursor, no regressions across the new render layer) need a real +kitty terminal — that's the live-QA gate in `docs/qa/QA-tui-v2.md`. **§E approval-ergonomics re-audit — DONE (`db27b34`).** Re-checked the band after it gained the grant scope-picker (`A`) and inline action rows: the spec still holds — single-key `y/n/e` for diff --git a/docs/qa/QA-tui-v2.md b/docs/qa/QA-tui-v2.md new file mode 100644 index 00000000..2db04a36 --- /dev/null +++ b/docs/qa/QA-tui-v2.md @@ -0,0 +1,52 @@ +# QA Plan: TUI Bubble Tea v2 + native cursor — `d247b19` `85af2c6` + +**Status:** DRAFT +**Run date / operator:** +**BACKLOG item:** none — operator-requested TUI work, already archived in `RETRO.md` +(2026-06-22). This plan does not gate a BACKLOG→RETRO move; it gates the *behavioral* +claims that `go test` can't prove (Shift+Enter, the real cursor, no render regressions), +which need a real kitty-protocol terminal. + +--- + +## Preconditions + +- [ ] **terminal:** kitty (or any kitty-keyboard-protocol terminal: ghostty / foot / wezterm / recent iTerm2) — Shift+Enter disambiguation depends on it. +- [ ] **tui build/branch:** `feat/backlog-burndown` at `85af2c6` — `cd apps/tui-go && GOTOOLCHAIN=auto go build -o /tmp/correx-tui .` +- [ ] **server up:** a correx server reachable at `--host/--port` (default `localhost:8080`) with at least one resumable session, so in-session views render. _Rebuild the server only while it's stopped (lazy classloading)._ +- [ ] **config synced:** `~/.config/correx` matches `examples/*` (stale copies cause silent bugs). +- [ ] _(optional)_ `CORREX_TUI_LOG=/tmp/tui.log` to capture the `KEY type=… alt=… ` debug lines as corroborating evidence for the key checks. + +## Acceptance gate (one sentence) + +> The TUI is correct on v2 **iff** Shift+Enter inserts a newline in the composer without +> sending, a real blinking cursor tracks the caret in insert mode (and vanishes in normal +> mode / behind modals), and nothing in the render layer regressed from the v1 build. + +## Checks + +Evidence is the live TUI render (an allowed source) plus, where noted, the `CORREX_TUI_LOG` key lines. + +| # | Action | Expected observable evidence | Result | +|---|--------|------------------------------|--------| +| 1 | Enter a session, press `i`, type a word | A blinking vertical-bar cursor sits at the caret; typing extends it. Log shows `KEY … type=keyRunes`. | | +| 2 | With text in the composer, press **Shift+Enter** | A newline is inserted; the input grows to a second row; nothing is submitted. Log line shows the Enter key with `shift` (no `submit`). | | +| 3 | Press **Enter** (bare) | The message submits (composer clears / turn appears). Confirms bare Enter still sends. | | +| 4 | Press **Ctrl+J**, then **Alt+Enter** (fallback paths) | Each inserts a newline without sending (parity with Shift+Enter). | | +| 5 | From insert mode press `esc` to normal mode | The blinking cursor disappears entirely (no stray bar left on screen). | | +| 6 | In insert mode, open the palette (`/` or `p`) | The modal's own filter caret blinks; **no** second cursor shows behind/around the modal. Close it → composer cursor returns. | | +| 7 | Leave the composer in insert mode and idle ~5s, then drag-select text with the mouse | Selection holds (screen isn't being redrawn out from under it) — confirms insert mode no longer forces the frame loop. | | +| 8 | Observe the terminal tab/title while in a session vs. idle | Title reads `correx — ` in-session; `correx — ` (or `correx`) when idle. | | +| 9 | Visual regression sweep: open `?` help, stats, an `e` event inspector, a `^x` diff, the `t` artifacts viewer; resize the terminal narrow↔wide | Colors/borders/opaque panels look as before; modals scroll (`PgUp/PgDn`, `g/G`); diff viewer pages; no garbled ANSI, no off-by-one panel widths, alt-screen restores the shell cleanly on `q`. | | +| 10 | Walk the approval band (trigger a gate), `↑/↓` the queue, approve/reject/steer | Band behaves exactly as on v1 (single-key low tiers, arm→confirm T3+); steering caret still draws. | | + +## Out of scope (explicitly NOT covered this pass) + +- Mouse-wheel scroll (deliberately not added — would fight native text selection). +- Native cursor for the palette/files/steering/config carets (still frame-drawn `▏`; only the main composer moved to `View.Cursor`). +- Behavior on non-kitty terminals beyond the documented Ctrl+J/Alt+Enter fallback. + +## Disposition + +- **PASS** → set Status: PASSED with the run date; the RETRO 2026-06-22 rows for `d247b19`/`85af2c6` stand as live-verified. +- **FAIL** → file each failure as a numbered finding in `BACKLOG.md` (action + exact repro + the render/log signal that was wrong), fix, then re-run only the failed checks. Set Status: FAILED until green. diff --git a/docs/qa/README.md b/docs/qa/README.md index 31c44bdf..69549325 100644 --- a/docs/qa/README.md +++ b/docs/qa/README.md @@ -18,9 +18,11 @@ to RETRO with the run date + cited evidence; on FAIL → refile each miss as a n | `QA-reviewer-static-first.md` | reviewer static-findings exclusion (PARTIAL — producing stage not built) | `447fc7a` | seed `StaticFindingsRecordedEvent` | | `QA-brief-echo-gate.md` | **ARM-IT** plan: brief echo-back gate (off by default; risky to arm blind) | `1df7af5` | prod-candidate model | | `QA-grants.md` | cross-session grants (PROJECT/GLOBAL) + revoke | `c36d41b` `8df0ec7` | model + a T2+ tool call, 2 sessions, 2 workspaces | +| `QA-tui-v2.md` | Bubble Tea v2 + Shift+Enter + native cursor + window title | `d247b19` `85af2c6` | **kitty-protocol terminal** + a server with a resumable session | ## Order of attack (suggested) +0. **`QA-tui-v2`** — cheapest of all: no model / network / GPU, just a kitty terminal + a server with a session to enter. Pure render/keyboard verification. 1. **`QA-llama-health-probe`** + **`QA-idea-promotion`** — cheapest, least model-dependent (health is liveness; promotion can be seeded). Quick wins. 2. **`QA-grants`** — no network/embedder; just a model that drives one repeatable T2+ tool call. The headline check (grant in session A → auto-clear in session B) is a strong, cheap signal. 3. **`QA-research-egress`** — once SearXNG is up. From e46777e29fa9dc0b30406ef2dbeceee7dcf2a19b Mon Sep 17 00:00:00 2001 From: kami Date: Mon, 22 Jun 2026 21:14:00 +0000 Subject: [PATCH 071/107] =?UTF-8?q?kernel:=20deterministic=20static=5Fchec?= =?UTF-8?q?k=20stage=20(=C2=A7B-=C2=A75)=20+=20critique-outcome=20producer?= =?UTF-8?q?=20(=C2=A7B-=C2=A76)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two reviewer-reliability tracks, both deterministic and unit-verified (no model/network). §B-§5 — static_check stage seam. The StaticAnalysisRunner + StaticFindingsRecordedEvent + reviewer-context filter already existed; what was missing was a stage that runs the tool and emits the event. Added: - StaticCheckStageExecutor: reads stage metadata (static_tool/static_argv), runs the configured command via StaticAnalysisRunner, returns a StaticFindingsRecordedEvent. No-op (empty findings) when no runner is wired or no command is configured — safe to carry unconfigured. - A deterministic-stage seam in DefaultSessionOrchestrator.enterStage: any stage with metadata["stage_type"] == "static_check" is run by the executor instead of the LLM subagent, then advances on its (unconditional) exit edge. - TomlWorkflowLoader: stage_type/static_tool/static_argv fields → StageConfig.metadata. - role_pipeline.toml: a static_check stage between implementer and reviewer (no-op until static_argv + a CommandRunner are set; activation is live-QA-gated, see StaticAnalysisRunner doc). §B-§6 — critique-outcome producer. The CritiqueFinding type + CriticCalibrationProjection existed but nothing fed them. Added: - CritiqueFindingsRecordedEvent (+ CritiqueVerdict): the producing side — a critic's findings + verdict for one review iteration, carrying modelHash for per-model calibration. - CritiqueOutcomeCorrelator: pure loop-resolution logic deciding UPHELD (fixed between rounds) / DISMISSED (persisted into an approved final) / INCONCLUSIVE (open at a non-approved terminal), per critic (role + modelHash) and per finding id. - A hook in completeWorkflow/failWorkflow that correlates recorded findings into CritiqueOutcomeCorrelatedEvents at loop resolution — no-op when none recorded, idempotent. (LLM-side finding emission stays a separate model-gated activation.) Tests: StaticCheckStageExecutorTest (4), StaticCheckStageTest integration (1, fake runner → event + transition), CritiqueOutcomeCorrelatorTest (8), CritiqueCalibrationWiringTest integration (1, seeded findings → outcomes at completion), updated RolePipelineWorkflowTest. Full suites for core:events/kernel/critique, infrastructure:workflow, testing:integration green; detekt clean. Co-Authored-By: Claude Opus 4.8 --- .../events/CritiqueCalibrationEvents.kt | 29 ++++ .../events/serialization/Serialization.kt | 2 + .../CritiqueOutcomeCorrelator.kt | 72 ++++++++ .../DefaultSessionOrchestrator.kt | 18 ++ .../orchestration/SessionOrchestrator.kt | 18 ++ .../orchestration/StaticAnalysisRunner.kt | 22 +-- .../orchestration/StaticCheckStageExecutor.kt | 72 ++++++++ .../CritiqueOutcomeCorrelatorTest.kt | 131 ++++++++++++++ .../StaticCheckStageExecutorTest.kt | 80 +++++++++ examples/workflows/role_pipeline.toml | 27 ++- .../workflow/TomlWorkflowLoader.kt | 27 ++- .../workflow/RolePipelineWorkflowTest.kt | 15 +- .../kotlin/CritiqueCalibrationWiringTest.kt | 161 ++++++++++++++++++ .../src/test/kotlin/StaticCheckStageTest.kt | 158 +++++++++++++++++ 14 files changed, 809 insertions(+), 23 deletions(-) create mode 100644 core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/CritiqueOutcomeCorrelator.kt create mode 100644 core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/StaticCheckStageExecutor.kt create mode 100644 core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/CritiqueOutcomeCorrelatorTest.kt create mode 100644 core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/StaticCheckStageExecutorTest.kt create mode 100644 testing/integration/src/test/kotlin/CritiqueCalibrationWiringTest.kt create mode 100644 testing/integration/src/test/kotlin/StaticCheckStageTest.kt diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/CritiqueCalibrationEvents.kt b/core/events/src/main/kotlin/com/correx/core/events/events/CritiqueCalibrationEvents.kt index 908f202e..ab63058c 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/events/CritiqueCalibrationEvents.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/events/CritiqueCalibrationEvents.kt @@ -1,9 +1,38 @@ package com.correx.core.events.events import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +/** A critic's verdict for one review iteration. */ +@Serializable +enum class CritiqueVerdict { APPROVED, CHANGES_REQUESTED } + +/** + * The findings a critic (plan critic or code reviewer) raised in one review iteration, plus its + * [verdict] for that iteration. This is the **producing side** of critique calibration: the + * reviewer-loop runtime correlates the sequence of these across iterations into + * [CritiqueOutcomeCorrelatedEvent]s (see CritiqueOutcomeCorrelator), which the + * `:core:critique` projection then folds into per-(model, role) precision. + * + * [iteration] is the refinement-loop iteration (1-based) this review belongs to, so the + * correlator can order reviews and tell whether a finding was fixed between rounds. [modelHash] + * identifies the model that produced the findings (carried through to the outcome event so + * calibration is per-model). + */ +@Serializable +@SerialName("CritiqueFindingsRecorded") +data class CritiqueFindingsRecordedEvent( + val sessionId: SessionId, + val stageId: StageId, + val role: CritiqueRole, + val modelHash: String, + val iteration: Int, + val verdict: CritiqueVerdict, + val findings: List, +) : EventPayload + /** * How a prior [CritiqueFinding] actually turned out once the surrounding loop resolved. * UPHELD = the finding was confirmed/actioned; DISMISSED = rejected as a false positive; diff --git a/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt b/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt index fc752c38..533032f2 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt @@ -15,6 +15,7 @@ import com.correx.core.events.events.ChatSessionStartedEvent import com.correx.core.events.events.ClarificationAnsweredEvent import com.correx.core.events.events.ClarificationRequestedEvent import com.correx.core.events.events.ChatTurnEvent +import com.correx.core.events.events.CritiqueFindingsRecordedEvent import com.correx.core.events.events.CritiqueOutcomeCorrelatedEvent import com.correx.core.events.events.RouterNarrationEvent import com.correx.core.events.events.OperatorProfileBoundEvent @@ -142,6 +143,7 @@ val eventModule = SerializersModule { subclass(IdeaDiscardedEvent::class) subclass(IdeaPromotedEvent::class) subclass(CritiqueOutcomeCorrelatedEvent::class) + subclass(CritiqueFindingsRecordedEvent::class) subclass(StageCheckpointPassedEvent::class) subclass(StageCheckpointFailedEvent::class) subclass(PossibleContradictionFlaggedEvent::class) diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/CritiqueOutcomeCorrelator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/CritiqueOutcomeCorrelator.kt new file mode 100644 index 00000000..cde4f074 --- /dev/null +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/CritiqueOutcomeCorrelator.kt @@ -0,0 +1,72 @@ +package com.correx.core.kernel.orchestration + +import com.correx.core.events.events.CritiqueFinding +import com.correx.core.events.events.CritiqueFindingsRecordedEvent +import com.correx.core.events.events.CritiqueOutcome +import com.correx.core.events.events.CritiqueOutcomeCorrelatedEvent +import com.correx.core.events.events.CritiqueVerdict +import com.correx.core.events.types.SessionId + +/** + * Decides how each prior critique finding turned out across a review loop — the producer half of + * critique calibration (BACKLOG §B-§6 / pipeline-addenda A3). Pure over the recorded review + * iterations, so it is replay-deterministic and unit-testable; the orchestrator runs it once at + * loop resolution (see completeWorkflow / failWorkflow) and emits the results, which the + * `:core:critique` projection folds into per-(model, role) precision. + * + * Rules, per critic (grouped by role + modelHash) and per finding id: + * - **UPHELD** — the finding was raised in some iteration and is gone by the final review: the + * implementer fixed it between rounds, so the critic was right and the finding was actioned. + * - **DISMISSED** — the finding persists into the final review AND that review APPROVED: the + * reviewer signed off without it being addressed, i.e. treated it as non-blocking. + * - **INCONCLUSIVE** — the finding is still open at a non-approved terminal (loop exhausted): no + * signal either way. + * + * Findings carry a stable [CritiqueFinding.id] so the same logical finding is tracked across + * iterations. + */ +object CritiqueOutcomeCorrelator { + + fun correlate( + sessionId: SessionId, + reviews: List, + ): List = + reviews + .groupBy { it.role to it.modelHash } + .flatMap { (_, group) -> correlateCritic(sessionId, group) } + + private fun correlateCritic( + sessionId: SessionId, + critic: List, + ): List { + val ordered = critic.sortedBy { it.iteration } + val finalReview = ordered.last() + val finalIds = finalReview.findings.mapTo(mutableSetOf()) { it.id } + + // Latest appearance per finding id (later iterations overwrite), used for severity and the + // owning critic's role/modelHash. + val latest = LinkedHashMap>() + for (review in ordered) { + for (finding in review.findings) { + latest[finding.id] = review to finding + } + } + + return latest.map { (id, appearance) -> + val (review, finding) = appearance + val outcome = when { + id !in finalIds -> CritiqueOutcome.UPHELD + finalReview.verdict == CritiqueVerdict.APPROVED -> CritiqueOutcome.DISMISSED + else -> CritiqueOutcome.INCONCLUSIVE + } + CritiqueOutcomeCorrelatedEvent( + sessionId = sessionId, + findingId = id, + role = review.role, + modelHash = review.modelHash, + severity = finding.severity, + outcome = outcome, + ) + } + } +} diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/DefaultSessionOrchestrator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/DefaultSessionOrchestrator.kt index b6407959..6cb8ae37 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/DefaultSessionOrchestrator.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/DefaultSessionOrchestrator.kt @@ -61,11 +61,15 @@ class DefaultSessionOrchestrator( private val compactionService: JournalCompactionService? = null, artifactKindRegistry: ArtifactKindRegistry? = null, repoKnowledgeRetriever: RepoKnowledgeRetriever? = null, + commandRunner: CommandRunner? = null, ) : SessionOrchestrator(repositories, engines, artifactStore, decisionJournalRepository, artifactKindRegistry = artifactKindRegistry, repoKnowledgeRetriever = repoKnowledgeRetriever), ApprovalGateway { override val tokenizer: Tokenizer? = tokenizer override val cancellations: ConcurrentHashMap = ConcurrentHashMap() + // Deterministic static_check stages (BACKLOG §B-§5) are run here, not via the LLM subagent. + private val staticCheckExecutor = StaticCheckStageExecutor(commandRunner) + override val subagentRunner: SubagentRunner = InSessionSubagentRunner( executeStage = { sid, stg, graph, session, cfg -> executeStage(sid, stg, graph, session, cfg, effectivesFor(cfg)) @@ -375,6 +379,20 @@ class DefaultSessionOrchestrator( } } + // Deterministic stage seam (§B-§5): a `static_check` stage runs a static-analysis tool and + // records its findings instead of invoking the LLM subagent. No retries/clarification apply. + ctx.graph.stages[stageId]?.let { stage -> + if (stage.metadata["stage_type"] == "static_check") { + val event = staticCheckExecutor.execute(ctx.sessionId, stageId, stage) + emit(ctx.sessionId, event) + log.info( + "[Orchestrator] static_check session={} stage={} tool={} findings={}", + ctx.sessionId.value, stageId.value, event.tool, event.findings.size, + ) + return StepResult.Continue(ctx.copy(stageCount = ctx.stageCount + 1)) + } + } + while (true) { if (isCancelled(ctx.sessionId)) { return StepResult.Terminal(handleCancellation(ctx.sessionId, stageId)) diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt index 87731aab..34e938ed 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt @@ -30,6 +30,8 @@ import com.correx.core.events.events.RefinementIterationEvent import com.correx.core.events.events.ApprovalRequestedEvent import com.correx.core.events.events.ArtifactContentStoredEvent import com.correx.core.events.events.ArtifactCreatedEvent +import com.correx.core.events.events.CritiqueFindingsRecordedEvent +import com.correx.core.events.events.CritiqueOutcomeCorrelatedEvent import com.correx.core.events.events.StaticFindingsRecordedEvent import com.correx.core.events.events.BriefEchoMismatchEvent import com.correx.core.events.events.BriefGroundingCheckedEvent @@ -1734,6 +1736,7 @@ abstract class SessionOrchestrator( "[Orchestrator] COMPLETED session={} terminalStage={} stages={}", sessionId.value, terminalStageId.value, stageCount, ) + correlateCritiqueOutcomes(sessionId) emit(sessionId, WorkflowCompletedEvent(sessionId, terminalStageId, stageCount)) cancellations.remove(sessionId) return WorkflowResult.Completed(sessionId, terminalStageId) @@ -1749,11 +1752,26 @@ abstract class SessionOrchestrator( "[Orchestrator] FAILED session={} stage={} reason={} retryExhausted={}", sessionId.value, stageId.value, reason, retryExhausted, ) + correlateCritiqueOutcomes(sessionId) emit(sessionId, WorkflowFailedEvent(sessionId, stageId, reason, retryExhausted)) cancellations.remove(sessionId) return WorkflowResult.Failed(sessionId, reason, retryExhausted) } + /** + * At loop resolution, correlate any recorded critique findings (§B-§6) into outcome events that + * feed the critic-calibration projection. A no-op when no [CritiqueFindingsRecordedEvent]s were + * recorded (the LLM-side finding emission is a separate, model-gated activation), and idempotent + * — it skips when outcomes already exist — so it is safe on every terminal path. + */ + private suspend fun correlateCritiqueOutcomes(sessionId: SessionId) { + val events = eventStore.read(sessionId) + if (events.any { it.payload is CritiqueOutcomeCorrelatedEvent }) return + val reviews = events.mapNotNull { it.payload as? CritiqueFindingsRecordedEvent } + if (reviews.isEmpty()) return + CritiqueOutcomeCorrelator.correlate(sessionId, reviews).forEach { emit(sessionId, it) } + } + internal suspend fun handleCancellation( sessionId: SessionId, stageId: StageId, diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/StaticAnalysisRunner.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/StaticAnalysisRunner.kt index 011279ae..7e820e4e 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/StaticAnalysisRunner.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/StaticAnalysisRunner.kt @@ -25,18 +25,18 @@ interface CommandRunner { * Both stdout and stderr are parsed — compilers write diagnostics to stderr, ktlint/detekt to * stdout — and parsing is lenient: lines that don't match any known format are ignored. * - * TODO(wiring): a real `static_check` stage in role_pipeline.toml (between `implementer` and - * `reviewer`) would invoke this runner and emit the findings as a first-class event: + * Wiring: the deterministic `static_check` stage now exists. [StaticCheckStageExecutor] invokes this + * runner and records a [com.correx.core.events.events.StaticFindingsRecordedEvent]; the orchestrator + * dispatches any stage with `metadata["stage_type"] == "static_check"` to it instead of the LLM + * subagent (see DefaultSessionOrchestrator.enterStage), and role_pipeline.toml carries the stage + * between `implementer` and `reviewer`. Once the event is on the journal, + * [excludeStaticFindingsFromReview] (already live-wired in SessionOrchestrator's context assembly) + * strips the matching entries from the reviewer's context. * - * val findings = StaticAnalysisRunner(commandRunner).analyze("detekt", argv) - * eventDispatcher.emit(StaticFindingsRecordedEvent(sessionId, stageId, "detekt", findings), sessionId) - * - * Once that event is on the journal, [excludeStaticFindingsFromReview] (already live-wired in - * SessionOrchestrator's context assembly) strips the matching entries from the reviewer's context. - * That stage is intentionally NOT added here: workflow stages are LLM-driven and emit typed - * artifacts, so a deterministic event-emitting stage needs a new stage-type / tool-execution seam - * that doesn't yet exist — adding it now would be broad plumbing. The runner + event + filter are - * complete; only the stage that calls them is deferred. + * The stage is a no-op until a [CommandRunner] is wired into the orchestrator AND the stage sets + * `static_argv`. TODO(activation): wiring a *production* runner means resolving the command to the + * session's workspace (ShellTool-style ProcessBuilder + cwd + timeout) and is live-QA-gated — it is + * deferred so the deterministic seam can land and be unit-verified without a sandbox. */ class StaticAnalysisRunner(private val runner: CommandRunner) { diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/StaticCheckStageExecutor.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/StaticCheckStageExecutor.kt new file mode 100644 index 00000000..23719f88 --- /dev/null +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/StaticCheckStageExecutor.kt @@ -0,0 +1,72 @@ +package com.correx.core.kernel.orchestration + +import com.correx.core.events.events.StaticFindingsRecordedEvent +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.transitions.graph.StageConfig + +/** + * Executes a deterministic `static_check` stage (BACKLOG §B-§5). Where an LLM stage runs a + * subagent, this stage shells out to a configured static-analysis tool, parses its console + * output via [StaticAnalysisRunner], and produces a [StaticFindingsRecordedEvent] — the event the + * reviewer-context filter ([excludeStaticFindingsFromReview], already live-wired) consumes so the + * reviewer's attention isn't spent re-finding what the build already reports. + * + * The stage is configured entirely through [StageConfig.metadata] (set from the workflow TOML): + * - `stage_type = "static_check"` — marks the stage deterministic (dispatched in `enterStage`). + * - `static_tool = "detekt"` — the label recorded on the findings (default `"static"`). + * - `static_argv = ""` — the command to run, whitespace-split (simple quotes honoured). + * + * It is a **no-op (empty findings) when no runner is wired or no `static_argv` is configured**, so a + * workflow can carry the stage safely before a tool/command is set up — activation is opt-in. + */ +class StaticCheckStageExecutor(private val commandRunner: CommandRunner?) { + + /** Runs the configured tool (if any) and returns the event to record; never throws on a tool miss. */ + suspend fun execute(sessionId: SessionId, stageId: StageId, stage: StageConfig): StaticFindingsRecordedEvent { + val tool = stage.metadata["static_tool"]?.takeIf { it.isNotBlank() } ?: "static" + val argv = stage.metadata["static_argv"]?.let { tokenizeArgv(it) } ?: emptyList() + val findings = if (commandRunner != null && argv.isNotEmpty()) { + StaticAnalysisRunner(commandRunner).analyze(tool, argv) + } else { + emptyList() + } + return StaticFindingsRecordedEvent( + sessionId = sessionId, + stageId = stageId, + tool = tool, + findings = findings, + ) + } + + companion object { + /** + * Splits a command string into argv on whitespace, honouring simple single- or double-quoted + * runs so a path or flag value may contain spaces. Not a full shell parser (no escapes, no + * variable expansion) — deliberately minimal and deterministic for unit testing. + */ + fun tokenizeArgv(command: String): List { + val tokens = mutableListOf() + val current = StringBuilder() + var quote: Char? = null + var inToken = false + fun flush() { + if (inToken) { + tokens.add(current.toString()) + current.clear() + inToken = false + } + } + for (ch in command) { + when { + quote != null -> if (ch == quote) quote = null else current.append(ch) + ch == '\'' || ch == '"' -> { quote = ch; inToken = true } + ch.isWhitespace() -> flush() + else -> { current.append(ch); inToken = true } + } + } + flush() + return tokens + } + } +} diff --git a/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/CritiqueOutcomeCorrelatorTest.kt b/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/CritiqueOutcomeCorrelatorTest.kt new file mode 100644 index 00000000..072f2dc6 --- /dev/null +++ b/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/CritiqueOutcomeCorrelatorTest.kt @@ -0,0 +1,131 @@ +package com.correx.core.kernel.orchestration + +import com.correx.core.events.events.CritiqueFinding +import com.correx.core.events.events.CritiqueFindingsRecordedEvent +import com.correx.core.events.events.CritiqueOutcome +import com.correx.core.events.events.CritiqueOutcomeCorrelatedEvent +import com.correx.core.events.events.CritiqueRole +import com.correx.core.events.events.CritiqueSeverity +import com.correx.core.events.events.CritiqueVerdict +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class CritiqueOutcomeCorrelatorTest { + + private val sessionId = SessionId("s1") + + private fun finding(id: String, severity: CritiqueSeverity = CritiqueSeverity.MAJOR) = CritiqueFinding( + id = id, + role = CritiqueRole.CODE_REVIEWER, + severity = severity, + category = "correctness", + message = "msg-$id", + ) + + private fun review( + iteration: Int, + verdict: CritiqueVerdict, + findings: List, + role: CritiqueRole = CritiqueRole.CODE_REVIEWER, + modelHash: String = "model-A", + ) = CritiqueFindingsRecordedEvent( + sessionId = sessionId, + stageId = StageId("reviewer"), + role = role, + modelHash = modelHash, + iteration = iteration, + verdict = verdict, + findings = findings, + ) + + private fun outcomeFor(events: List, id: String) = + events.single { it.findingId == id }.outcome + + @Test + fun `finding fixed between iterations is UPHELD`() { + val reviews = listOf( + review(1, CritiqueVerdict.CHANGES_REQUESTED, listOf(finding("f1"))), + review(2, CritiqueVerdict.APPROVED, emptyList()), + ) + val out = CritiqueOutcomeCorrelator.correlate(sessionId, reviews) + assertEquals(1, out.size) + assertEquals(CritiqueOutcome.UPHELD, out.single().outcome) + } + + @Test + fun `finding persisting into an approved final review is DISMISSED`() { + val reviews = listOf( + review(1, CritiqueVerdict.CHANGES_REQUESTED, listOf(finding("f1"))), + review(2, CritiqueVerdict.APPROVED, listOf(finding("f1"))), + ) + val out = CritiqueOutcomeCorrelator.correlate(sessionId, reviews) + assertEquals(CritiqueOutcome.DISMISSED, outcomeFor(out, "f1")) + } + + @Test + fun `finding still open at a non-approved terminal is INCONCLUSIVE`() { + val reviews = listOf( + review(1, CritiqueVerdict.CHANGES_REQUESTED, listOf(finding("f1"))), + review(2, CritiqueVerdict.CHANGES_REQUESTED, listOf(finding("f1"))), + ) + val out = CritiqueOutcomeCorrelator.correlate(sessionId, reviews) + assertEquals(CritiqueOutcome.INCONCLUSIVE, outcomeFor(out, "f1")) + } + + @Test + fun `a single approved review with a finding dismisses it`() { + val reviews = listOf(review(1, CritiqueVerdict.APPROVED, listOf(finding("f1")))) + val out = CritiqueOutcomeCorrelator.correlate(sessionId, reviews) + assertEquals(CritiqueOutcome.DISMISSED, outcomeFor(out, "f1")) + } + + @Test + fun `mixed fates in one loop are resolved independently per finding`() { + val reviews = listOf( + // f1 and f2 raised; f1 fixed next round, f2 persists; f3 raised late and persists. + review(1, CritiqueVerdict.CHANGES_REQUESTED, listOf(finding("f1"), finding("f2"))), + review(2, CritiqueVerdict.APPROVED, listOf(finding("f2"), finding("f3"))), + ) + val out = CritiqueOutcomeCorrelator.correlate(sessionId, reviews) + assertEquals(3, out.size) + assertEquals(CritiqueOutcome.UPHELD, outcomeFor(out, "f1")) + assertEquals(CritiqueOutcome.DISMISSED, outcomeFor(out, "f2")) + assertEquals(CritiqueOutcome.DISMISSED, outcomeFor(out, "f3")) + } + + @Test + fun `findings carry their severity and the producing critic's identity`() { + val reviews = listOf( + review( + 1, CritiqueVerdict.APPROVED, listOf(finding("f1", CritiqueSeverity.BLOCKER)), + role = CritiqueRole.PLAN_CRITIC, modelHash = "model-Z", + ), + ) + val event = CritiqueOutcomeCorrelator.correlate(sessionId, reviews).single() + assertEquals(CritiqueSeverity.BLOCKER, event.severity) + assertEquals(CritiqueRole.PLAN_CRITIC, event.role) + assertEquals("model-Z", event.modelHash) + } + + @Test + fun `each critic (role + model) is calibrated separately`() { + val planCritic = CritiqueRole.PLAN_CRITIC + val reviewer = CritiqueRole.CODE_REVIEWER + val reviews = listOf( + review(1, CritiqueVerdict.CHANGES_REQUESTED, listOf(finding("a")), role = planCritic, modelHash = "m1"), + review(2, CritiqueVerdict.APPROVED, emptyList(), role = planCritic, modelHash = "m1"), + review(1, CritiqueVerdict.APPROVED, listOf(finding("b")), role = reviewer, modelHash = "m2"), + ) + val out = CritiqueOutcomeCorrelator.correlate(sessionId, reviews) + assertEquals(CritiqueOutcome.UPHELD, outcomeFor(out, "a")) // plan critic's finding fixed + assertEquals(CritiqueOutcome.DISMISSED, outcomeFor(out, "b")) // reviewer's finding approved-through + } + + @Test + fun `no reviews yields no outcomes`() { + assertTrue(CritiqueOutcomeCorrelator.correlate(sessionId, emptyList()).isEmpty()) + } +} diff --git a/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/StaticCheckStageExecutorTest.kt b/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/StaticCheckStageExecutorTest.kt new file mode 100644 index 00000000..f002a610 --- /dev/null +++ b/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/StaticCheckStageExecutorTest.kt @@ -0,0 +1,80 @@ +package com.correx.core.kernel.orchestration + +import com.correx.core.events.events.StaticFindingSeverity +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.transitions.graph.StageConfig +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class StaticCheckStageExecutorTest { + + private val sessionId = SessionId("s1") + private val stageId = StageId("static_check") + + private fun fakeRunner(stdout: String = "", stderr: String = "") = object : CommandRunner { + var lastArgv: List? = null + override suspend fun run(argv: List): CommandOutput { + lastArgv = argv + return CommandOutput(exitCode = 0, stdout = stdout, stderr = stderr) + } + } + + @Test + fun `tokenizeArgv splits on whitespace and honours quotes`() { + assertEquals( + listOf("./gradlew", "detekt", "--console=plain"), + StaticCheckStageExecutor.tokenizeArgv("./gradlew detekt --console=plain"), + ) + assertEquals( + listOf("tool", "--path", "src/main with space/App.kt"), + StaticCheckStageExecutor.tokenizeArgv("""tool --path "src/main with space/App.kt""""), + ) + assertEquals( + listOf("a", "b"), + StaticCheckStageExecutor.tokenizeArgv(" a b "), + ) + assertTrue(StaticCheckStageExecutor.tokenizeArgv("").isEmpty()) + } + + @Test + fun `execute runs the configured tool and records parsed findings`() = runBlocking { + val runner = fakeRunner(stdout = "src/A.kt:10:4: warning: deprecated\nBUILD FAILED") + val stage = StageConfig( + metadata = mapOf( + "stage_type" to "static_check", + "static_tool" to "detekt", + "static_argv" to "./gradlew detekt", + ), + ) + val event = StaticCheckStageExecutor(runner).execute(sessionId, stageId, stage) + + assertEquals("detekt", event.tool) + assertEquals(1, event.findings.size) + assertEquals("src/A.kt", event.findings[0].file) + assertEquals(StaticFindingSeverity.WARNING, event.findings[0].severity) + assertEquals(listOf("./gradlew", "detekt"), runner.lastArgv) + } + + @Test + fun `execute is a no-op when no argv is configured`() = runBlocking { + val runner = fakeRunner(stdout = "src/A.kt:1:1: error: boom") + val stage = StageConfig(metadata = mapOf("stage_type" to "static_check", "static_tool" to "detekt")) + val event = StaticCheckStageExecutor(runner).execute(sessionId, stageId, stage) + + assertEquals("detekt", event.tool) + assertTrue(event.findings.isEmpty(), "no static_argv → tool not run, empty findings") + assertEquals(null, runner.lastArgv, "the runner must not be invoked without a command") + } + + @Test + fun `execute is a no-op with empty findings when no runner is wired`() = runBlocking { + val stage = StageConfig(metadata = mapOf("stage_type" to "static_check", "static_argv" to "./gradlew detekt")) + val event = StaticCheckStageExecutor(null).execute(sessionId, stageId, stage) + + assertEquals("static", event.tool, "default tool label when unspecified") + assertTrue(event.findings.isEmpty()) + } +} diff --git a/examples/workflows/role_pipeline.toml b/examples/workflows/role_pipeline.toml index 92e5c3cb..e62175a1 100644 --- a/examples/workflows/role_pipeline.toml +++ b/examples/workflows/role_pipeline.toml @@ -1,4 +1,4 @@ -# Role pipeline: analyst → architect → planner → implementer ⇄ reviewer +# Role pipeline: analyst → architect → planner → implementer → static_check ⇄ reviewer # # Each stage produces a typed artifact that the next stage `needs`, so work flows forward # without a human relaying notes. The decision journal (pinned into every stage's context) @@ -69,6 +69,19 @@ allowed_tools = ["file_read", "file_write", "file_edit", "ShellTool"] token_budget = 32768 max_retries = 3 +# 4b. Deterministic static analysis (NO LLM). Runs a configured tool (compiler/ktlint/detekt) +# over the patch and records its findings as a StaticFindingsRecordedEvent, which the +# reviewer-context filter then strips from the reviewer's context so the LLM reviewer spends +# its attention on semantic review rather than re-finding what the build already reports. +# No-op until `static_argv` is set (and a CommandRunner is wired) — safe to carry unconfigured. +# Activate e.g. with: static_argv = "./gradlew detekt --console=plain" +[[stages]] +id = "static_check" +stage_type = "static_check" +static_tool = "detekt" +token_budget = 0 +max_retries = 0 + # 5. Review the patch against the plan AND the analyst's acceptance criteria. The reviewer # needs `analysis` so it judges the diff against concrete, pre-stated criteria (§5 narrow # question) rather than whole files against taste. @@ -103,13 +116,21 @@ to = "implementer" condition_type = "artifact_validated" condition_artifact_id = "impl_plan" +# implementer → static_check (once the patch is validated) → reviewer. The static_check stage +# produces no artifact, so its exit edge is unconditional (always_true). [[transitions]] -id = "implementer-to-reviewer" +id = "implementer-to-static_check" from = "implementer" -to = "reviewer" +to = "static_check" condition_type = "artifact_validated" condition_artifact_id = "patch" +[[transitions]] +id = "static_check-to-reviewer" +from = "static_check" +to = "reviewer" +condition_type = "always_true" + # --- verdict-gated loop exit / re-entry --- [[transitions]] diff --git a/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/TomlWorkflowLoader.kt b/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/TomlWorkflowLoader.kt index 1e94bd16..ece7c8dc 100644 --- a/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/TomlWorkflowLoader.kt +++ b/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/TomlWorkflowLoader.kt @@ -47,6 +47,12 @@ private data class StageSection( @param:JsonProperty("requires_approval") val requiresApproval: Boolean = false, @param:JsonProperty("inject_artifact_kinds") val injectArtifactKinds: Boolean = false, @param:JsonProperty("ground_references") val groundReferences: Boolean = false, + // Deterministic stage marker (§B-§5): `stage_type = "static_check"` runs a static-analysis + // tool instead of an LLM subagent; `static_tool`/`static_argv` configure it (see + // StaticCheckStageExecutor). Absent for ordinary LLM stages. + @param:JsonProperty("stage_type") val stageType: String? = null, + @param:JsonProperty("static_tool") val staticTool: String? = null, + @param:JsonProperty("static_argv") val staticArgv: String? = null, ) // condition fields flattened into the transition row @@ -93,6 +99,19 @@ class TomlWorkflowLoader( return if (candidate != null && candidate.exists()) candidate.toString() else raw } + // Flattens a stage's TOML flags into the StageConfig.metadata string map the runtime reads + // (prompt paths, approval/inject/ground flags, and the §B-§5 deterministic-stage markers). + private fun stageMetadata(s: StageSection, workflowDir: Path?): Map = buildMap { + s.prompt?.let { put("prompt", resolvePromptPath(it, workflowDir)) } + s.systemPrompt?.let { put("systemPrompt", resolvePromptPath(it, workflowDir)) } + if (s.requiresApproval) put("requiresApproval", "true") + if (s.injectArtifactKinds) put("injectArtifactKinds", "true") + if (s.groundReferences) put("groundReferences", "true") + s.stageType?.let { put("stage_type", it) } + s.staticTool?.let { put("static_tool", it) } + s.staticArgv?.let { put("static_argv", it) } + } + private fun WorkflowFile.toWorkflowGraph(workflowDir: Path?): WorkflowGraph { val startId = StageId(start) val stageMap = stages.associate { s -> @@ -115,13 +134,7 @@ class TomlWorkflowLoader( maxTokens = s.tokenBudget, ), maxRetries = s.maxRetries, - metadata = buildMap { - s.prompt?.let { put("prompt", resolvePromptPath(it, workflowDir)) } - s.systemPrompt?.let { put("systemPrompt", resolvePromptPath(it, workflowDir)) } - if (s.requiresApproval) put("requiresApproval", "true") - if (s.injectArtifactKinds) put("injectArtifactKinds", "true") - if (s.groundReferences) put("groundReferences", "true") - }, + metadata = stageMetadata(s, workflowDir), ) } diff --git a/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/RolePipelineWorkflowTest.kt b/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/RolePipelineWorkflowTest.kt index 17eaf553..f0eead46 100644 --- a/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/RolePipelineWorkflowTest.kt +++ b/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/RolePipelineWorkflowTest.kt @@ -53,10 +53,21 @@ class RolePipelineWorkflowTest { val graph = TomlWorkflowLoader(registry).load(path!!) assertEquals( - setOf("analyst", "architect", "planner", "implementer", "reviewer"), + setOf("analyst", "architect", "planner", "implementer", "static_check", "reviewer"), graph.stages.keys.map { it.value }.toSet(), ) - assertEquals(6, graph.transitions.size) + assertEquals(7, graph.transitions.size) + + // The deterministic static_check stage sits between implementer and reviewer. + assertEquals("static_check", graph.stages[StageId("static_check")]!!.metadata["stage_type"]) + assertTrue( + graph.transitions.any { it.from == StageId("implementer") && it.to == StageId("static_check") }, + "implementer should hand off to static_check", + ) + assertTrue( + graph.transitions.any { it.from == StageId("static_check") && it.to == StageId("reviewer") }, + "static_check should hand off to reviewer", + ) // Forward chaining via needs. assertTrue(graph.stages[StageId("reviewer")]!!.needs.map { it.value }.containsAll(listOf("patch", "impl_plan"))) diff --git a/testing/integration/src/test/kotlin/CritiqueCalibrationWiringTest.kt b/testing/integration/src/test/kotlin/CritiqueCalibrationWiringTest.kt new file mode 100644 index 00000000..51248dd0 --- /dev/null +++ b/testing/integration/src/test/kotlin/CritiqueCalibrationWiringTest.kt @@ -0,0 +1,161 @@ +import com.correx.core.approvals.ApprovalProjector +import com.correx.core.approvals.DefaultApprovalReducer +import com.correx.core.approvals.DefaultApprovalRepository +import com.correx.core.approvals.domain.DefaultApprovalEngine +import com.correx.core.artifacts.DefaultArtifactReducer +import com.correx.core.events.events.CritiqueFinding +import com.correx.core.events.events.CritiqueFindingsRecordedEvent +import com.correx.core.events.events.CritiqueOutcome +import com.correx.core.events.events.CritiqueOutcomeCorrelatedEvent +import com.correx.core.events.events.CritiqueRole +import com.correx.core.events.events.CritiqueSeverity +import com.correx.core.events.events.CritiqueVerdict +import com.correx.core.events.events.EventMetadata +import com.correx.core.events.events.NewEvent +import com.correx.core.events.types.EventId +import com.correx.core.events.execution.RetryPolicy +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.events.types.TransitionId +import com.correx.core.inference.InferenceRepository +import com.correx.core.inference.InferenceState +import com.correx.core.journal.DecisionJournalProjector +import com.correx.core.journal.DefaultDecisionJournalReducer +import com.correx.core.journal.DefaultDecisionJournalRepository +import com.correx.core.kernel.orchestration.DefaultOrchestrationReducer +import com.correx.core.kernel.orchestration.DefaultSessionOrchestrator +import com.correx.core.kernel.orchestration.OrchestrationConfig +import com.correx.core.kernel.orchestration.OrchestrationProjector +import com.correx.core.kernel.orchestration.OrchestrationRepository +import com.correx.core.kernel.orchestration.OrchestratorEngines +import com.correx.core.kernel.orchestration.OrchestratorRepositories +import com.correx.core.kernel.retry.DefaultRetryCoordinator +import com.correx.core.risk.DefaultRiskAssessor +import com.correx.core.sessions.DefaultSessionRepository +import com.correx.core.sessions.projections.replay.DefaultEventReplayer +import com.correx.core.sessions.projections.replay.EventReplayer +import com.correx.core.transitions.graph.StageConfig +import com.correx.core.transitions.graph.TransitionEdge +import com.correx.core.transitions.graph.WorkflowGraph +import com.correx.core.validation.pipeline.ValidationPipeline +import com.correx.infrastructure.persistence.InMemoryEventStore +import com.correx.infrastructure.persistence.artifact.LiveArtifactRepository +import com.correx.testing.contracts.fixtures.artifactstore.NoopArtifactStore +import com.correx.testing.fixtures.InferenceFixtures +import com.correx.testing.fixtures.context.ContextFixtures +import com.correx.testing.fixtures.cyclePolicyMissingValidator +import com.correx.testing.fixtures.transitions.TransitionFixtures +import com.correx.testing.kernel.MockSessionEventReplayer +import kotlinx.coroutines.runBlocking +import kotlinx.datetime.Clock +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** + * End-to-end proof that the reviewer-loop runtime correlates recorded critique findings into + * outcome events at loop resolution (BACKLOG §B-§6), and that those events feed the + * `:core:critique` calibration projection. The LLM-side emission of findings is mocked here by + * seeding [CritiqueFindingsRecordedEvent]s directly (that half is a separate model-gated step). + */ +class CritiqueCalibrationWiringTest { + + private val eventStore = InMemoryEventStore() + private val sessionReplayer = MockSessionEventReplayer() + private val sessionRepository = DefaultSessionRepository(sessionReplayer) + private val orchestrationRepository = OrchestrationRepository( + DefaultEventReplayer(eventStore, OrchestrationProjector(DefaultOrchestrationReducer())), + ) + private val inferenceRepository = InferenceRepository( + object : EventReplayer { + override fun rebuild(sessionId: SessionId) = InferenceState() + }, + ) + private val approvalRepository = DefaultApprovalRepository( + DefaultEventReplayer(eventStore, ApprovalProjector(DefaultApprovalReducer())), + ) + private val decisionJournalRepository = DefaultDecisionJournalRepository( + DefaultEventReplayer(eventStore, DecisionJournalProjector(DefaultDecisionJournalReducer())), + ) + private val retryCoordinator = DefaultRetryCoordinator(eventStore) + + private val repositories = OrchestratorRepositories( + eventStore = eventStore, + inferenceRepository = inferenceRepository, + orchestrationRepository = orchestrationRepository, + sessionRepository = sessionRepository, + artifactRepository = LiveArtifactRepository(eventStore, DefaultArtifactReducer()), + approvalRepository = approvalRepository, + ) + + private val orchestrator = DefaultSessionOrchestrator( + repositories = repositories, + engines = OrchestratorEngines( + transitionResolver = TransitionFixtures.simpleResolver(), + contextPackBuilder = ContextFixtures.simpleBuilder(), + inferenceRouter = InferenceFixtures.fixedRouter(), + validationPipeline = ValidationPipeline(validators = listOf(cyclePolicyMissingValidator())), + approvalEngine = DefaultApprovalEngine(), + riskAssessor = DefaultRiskAssessor(), + ), + retryCoordinator = retryCoordinator, + artifactStore = NoopArtifactStore(), + decisionJournalRepository = decisionJournalRepository, + ) + + private val defaultConfig = OrchestrationConfig(retryPolicy = RetryPolicy(maxAttempts = 1, backoffMs = 0)) + + private fun finding(id: String) = CritiqueFinding( + id = id, + role = CritiqueRole.CODE_REVIEWER, + severity = CritiqueSeverity.MAJOR, + category = "correctness", + message = "msg-$id", + ) + + private suspend fun seedReview(sessionId: SessionId, iteration: Int, verdict: CritiqueVerdict, findings: List) { + eventStore.append( + NewEvent( + EventMetadata(EventId("rev-$iteration"), sessionId, Clock.System.now(), 1, null, null), + CritiqueFindingsRecordedEvent( + sessionId = sessionId, + stageId = StageId("reviewer"), + role = CritiqueRole.CODE_REVIEWER, + modelHash = "model-A", + iteration = iteration, + verdict = verdict, + findings = findings, + ), + ), + ) + } + + @Test + fun `recorded findings are correlated at completion and feed the calibration projection`(): Unit = runBlocking { + val sessionId = SessionId("calib-1") + + // Two review rounds: f1 fixed between them (→ UPHELD); f2 persists to an approved final (→ DISMISSED). + seedReview(sessionId, 1, CritiqueVerdict.CHANGES_REQUESTED, listOf(finding("f1"), finding("f2"))) + seedReview(sessionId, 2, CritiqueVerdict.APPROVED, listOf(finding("f2"))) + + val stageA = StageId("A") + val done = StageId("done") + val graph = WorkflowGraph( + id = "calib-wiring", + stages = mapOf(stageA to StageConfig(), done to StageConfig()), + transitions = setOf(TransitionEdge(id = TransitionId("t1"), from = stageA, to = done, condition = { true })), + start = stageA, + ) + + orchestrator.run(sessionId, graph, defaultConfig) + + // The orchestrator emits one outcome per recorded finding at completion — the events the + // :core:critique calibration projection consumes (its folding is covered by that module's + // DefaultCriticCalibrationReducerTest). + val outcomes = eventStore.read(sessionId).mapNotNull { it.payload as? CritiqueOutcomeCorrelatedEvent } + assertEquals(2, outcomes.size, "one outcome per recorded finding") + assertEquals(CritiqueOutcome.UPHELD, outcomes.single { it.findingId == "f1" }.outcome) + assertEquals(CritiqueOutcome.DISMISSED, outcomes.single { it.findingId == "f2" }.outcome) + assertTrue(outcomes.all { it.modelHash == "model-A" && it.role == CritiqueRole.CODE_REVIEWER }) + } +} diff --git a/testing/integration/src/test/kotlin/StaticCheckStageTest.kt b/testing/integration/src/test/kotlin/StaticCheckStageTest.kt new file mode 100644 index 00000000..28fb0b90 --- /dev/null +++ b/testing/integration/src/test/kotlin/StaticCheckStageTest.kt @@ -0,0 +1,158 @@ +import com.correx.core.approvals.ApprovalProjector +import com.correx.core.approvals.DefaultApprovalReducer +import com.correx.core.approvals.DefaultApprovalRepository +import com.correx.core.approvals.domain.DefaultApprovalEngine +import com.correx.core.artifacts.DefaultArtifactReducer +import com.correx.core.events.events.StaticFindingsRecordedEvent +import com.correx.core.events.events.WorkflowCompletedEvent +import com.correx.core.events.execution.RetryPolicy +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.events.types.TransitionId +import com.correx.core.inference.InferenceRepository +import com.correx.core.inference.InferenceState +import com.correx.core.journal.DecisionJournalProjector +import com.correx.core.journal.DefaultDecisionJournalReducer +import com.correx.core.journal.DefaultDecisionJournalRepository +import com.correx.core.kernel.orchestration.CommandOutput +import com.correx.core.kernel.orchestration.CommandRunner +import com.correx.core.kernel.orchestration.DefaultOrchestrationReducer +import com.correx.core.kernel.orchestration.DefaultSessionOrchestrator +import com.correx.core.kernel.orchestration.OrchestrationConfig +import com.correx.core.kernel.orchestration.OrchestrationProjector +import com.correx.core.kernel.orchestration.OrchestrationRepository +import com.correx.core.kernel.orchestration.OrchestratorEngines +import com.correx.core.kernel.orchestration.OrchestratorRepositories +import com.correx.core.kernel.retry.DefaultRetryCoordinator +import com.correx.core.risk.DefaultRiskAssessor +import com.correx.core.sessions.DefaultSessionRepository +import com.correx.core.sessions.projections.replay.DefaultEventReplayer +import com.correx.core.sessions.projections.replay.EventReplayer +import com.correx.core.transitions.graph.StageConfig +import com.correx.core.transitions.graph.TransitionEdge +import com.correx.core.transitions.graph.WorkflowGraph +import com.correx.core.validation.pipeline.ValidationPipeline +import com.correx.infrastructure.persistence.InMemoryEventStore +import com.correx.infrastructure.persistence.artifact.LiveArtifactRepository +import com.correx.testing.contracts.fixtures.artifactstore.NoopArtifactStore +import com.correx.testing.fixtures.InferenceFixtures +import com.correx.testing.fixtures.context.ContextFixtures +import com.correx.testing.fixtures.cyclePolicyMissingValidator +import com.correx.testing.fixtures.transitions.TransitionFixtures +import com.correx.testing.kernel.MockSessionEventReplayer +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Test + +/** + * End-to-end proof of the deterministic static_check stage seam (BACKLOG §B-§5): a stage marked + * `stage_type = "static_check"` runs the configured tool via an injected [CommandRunner] (no LLM + * subagent), records a [StaticFindingsRecordedEvent] with the parsed findings, and the workflow + * advances on the unconditional exit edge. + */ +class StaticCheckStageTest { + + private val eventStore = InMemoryEventStore() + private val sessionReplayer = MockSessionEventReplayer() + private val sessionRepository = DefaultSessionRepository(sessionReplayer) + private val orchestrationRepository = OrchestrationRepository( + DefaultEventReplayer(eventStore, OrchestrationProjector(DefaultOrchestrationReducer())), + ) + private val inferenceRepository = InferenceRepository( + object : EventReplayer { + override fun rebuild(sessionId: SessionId) = InferenceState() + }, + ) + private val approvalRepository = DefaultApprovalRepository( + DefaultEventReplayer(eventStore, ApprovalProjector(DefaultApprovalReducer())), + ) + private val decisionJournalRepository = DefaultDecisionJournalRepository( + DefaultEventReplayer(eventStore, DecisionJournalProjector(DefaultDecisionJournalReducer())), + ) + private val retryCoordinator = DefaultRetryCoordinator(eventStore) + + private val repositories = OrchestratorRepositories( + eventStore = eventStore, + inferenceRepository = inferenceRepository, + orchestrationRepository = orchestrationRepository, + sessionRepository = sessionRepository, + artifactRepository = LiveArtifactRepository(eventStore, DefaultArtifactReducer()), + approvalRepository = approvalRepository, + ) + + // Canned tool output: one stderr error + one stdout detekt finding (+ noise that must be ignored). + private val fakeRunner = object : CommandRunner { + var lastArgv: List? = null + override suspend fun run(argv: List): CommandOutput { + lastArgv = argv + return CommandOutput( + exitCode = 1, + stdout = "src/Foo.kt:42:9: Magic number found [MagicNumber]\nBUILD FAILED", + stderr = "src/Bar.kt:5:9: error: type mismatch", + ) + } + } + + private val orchestrator = DefaultSessionOrchestrator( + repositories = repositories, + engines = OrchestratorEngines( + transitionResolver = TransitionFixtures.simpleResolver(), + contextPackBuilder = ContextFixtures.simpleBuilder(), + inferenceRouter = InferenceFixtures.fixedRouter(), + validationPipeline = ValidationPipeline(validators = listOf(cyclePolicyMissingValidator())), + approvalEngine = DefaultApprovalEngine(), + riskAssessor = DefaultRiskAssessor(), + ), + retryCoordinator = retryCoordinator, + artifactStore = NoopArtifactStore(), + decisionJournalRepository = decisionJournalRepository, + commandRunner = fakeRunner, + ) + + private val defaultConfig = OrchestrationConfig( + retryPolicy = RetryPolicy(maxAttempts = 1, backoffMs = 0), + ) + + @Test + fun `static_check stage runs the tool deterministically and records findings`(): Unit = runBlocking { + val sessionId = SessionId("static-check-1") + val staticCheck = StageId("static_check") + val done = StageId("done") + + val graph = WorkflowGraph( + id = "static-check-seam", + stages = mapOf( + staticCheck to StageConfig( + metadata = mapOf( + "stage_type" to "static_check", + "static_tool" to "detekt", + "static_argv" to "./gradlew detekt --console=plain", + ), + ), + done to StageConfig(), + ), + transitions = setOf( + TransitionEdge(id = TransitionId("t1"), from = staticCheck, to = done, condition = { true }), + ), + start = staticCheck, + ) + + orchestrator.run(sessionId, graph, defaultConfig) + + val events = eventStore.read(sessionId) + val recorded = events.mapNotNull { it.payload as? StaticFindingsRecordedEvent } + assertEquals(1, recorded.size, "the static_check stage must record exactly one findings event") + val event = recorded.single() + assertEquals("detekt", event.tool) + assertEquals(2, event.findings.size, "both the stdout detekt finding and the stderr error parse") + assertEquals("MagicNumber", event.findings[0].ruleId) + assertEquals("src/Bar.kt", event.findings[1].file) + assertEquals(listOf("./gradlew", "detekt", "--console=plain"), fakeRunner.lastArgv) + + assertNotNull( + events.find { it.payload is WorkflowCompletedEvent }, + "workflow advances past static_check on the unconditional exit edge and completes", + ) + } +} From 33baaed903dfcc32d3737f83c826b8a373fc0e76 Mon Sep 17 00:00:00 2001 From: kami Date: Mon, 22 Jun 2026 21:52:59 +0000 Subject: [PATCH 072/107] =?UTF-8?q?docs:=20reconcile=20BACKLOG=E2=86=92RET?= =?UTF-8?q?RO=20for=20static=5Fcheck=20stage=20+=20critique=20producer=20(?= =?UTF-8?q?e46777e)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the hygiene rule: the §B-§5 static_check stage seam + emitter and the §B-§6/A3 critique-outcome producer are built and unit-verified, so move them out of the live "missing" list. Both narrowed to their remaining live-QA-gated *activations* (a sandboxed production CommandRunner for §B-§5; model-emitted CritiqueFindingsRecordedEvents for §6), with a dated RETRO entry + commit map. Updated QA-reviewer-static-first (the producing stage now exists — check 4 reframed from "blocked on unbuilt seam" to an activation step) and its README index line. Co-Authored-By: Claude Opus 4.8 --- BACKLOG.md | 29 +++++++++++++++++------------ RETRO.md | 17 +++++++++++++++++ docs/qa/QA-reviewer-static-first.md | 10 +++++----- docs/qa/README.md | 2 +- 4 files changed, 40 insertions(+), 18 deletions(-) diff --git a/BACKLOG.md b/BACKLOG.md index 8524cad1..0ac673f1 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -36,13 +36,13 @@ quick commit map: - **C-A2** stage-level plan checkpointing — `1a1b5cc` - **B §4** architect contradiction-check (display-only) — `eae0a0c`, **activated** `4e08510` (server subscribes to the architect `design` artifact → emits the flag) - **D** dedicated `SourceFetched`/`LowQualityExtraction` events — `b098d87`; dynamic per-session egress allowlist — `027ff1f`, **activated** `ab7e4be` (session state threaded into assessment input); batch source-list fetch-approval — `4b0024f` -- **B §5** reviewer static-first (runner + event + context-exclusion, live-wired) — `447fc7a` *(follow-up: a stage that emits `StaticFindingsRecordedEvent`)* +- **B §5** reviewer static-first (runner + event + context-exclusion, live-wired) — `447fc7a`; the deterministic `static_check` stage seam + emitter — `e46777e` *(remaining: production sandboxed-runner activation, live-QA)* - **G** narration-lane lag — `eff8f8d` - **E** tui-go go.mod directive fix — `1e6699a`; markdown rendering — `da72ee5`; session list / resume view — `1f7a5d9`; per-event render-matrix golden tests — `2a99e15`; approval ergonomics (queue + tier-gated confirm) — `d5afcd3` - **B §2** plan-derived diff manifest — `641051b` -**Remaining (deeper / lower-verifiability):** H freestyle follow-ups (graph re-route, child-session runner — deep session-lifecycle, want live testing); the dormant model-dependent activations (A1 planner prompt, A3 producer, B§5 static_check stage — live-QA-gated, deprioritized to last). §E plan-comparison view is BLOCKED (no plan-candidate event; see §E). All cleanly unit-verifiable deterministic tracks are now landed. +**Remaining (deeper / lower-verifiability):** H freestyle follow-ups (graph re-route, child-session runner — deep session-lifecycle, want live testing); the dormant *activations* whose deterministic halves are now built (A1 planner prompt; A3/§6 critique calibration — correlator + producer landed `e46777e`, awaiting model-emitted findings; B§5 static_check — seam + emitter landed `e46777e`, awaiting a sandboxed production runner — both live-QA-gated). §E plan-comparison view is BLOCKED (no plan-candidate event; see §E). All cleanly unit-verifiable deterministic tracks are now landed. **Still blocked in this sandbox:** F live-QA gates + D §6 web approval client (need local model / SearXNG / GPU). **📋 Live-QA plans drafted** (`docs/qa/QA-*.md`, index `docs/qa/README.md`, env `docs/qa/ENV.md` + `scripts/qa/`): @@ -71,13 +71,17 @@ health probes shipped (`266bbf0`, `64a90e7`) → see RETRO. Remaining: `CritiqueFinding` type + calibration projection (`f783eed`), and §4 architect contradiction-check (`eae0a0c`/`4e08510`) are shipped — see RETRO. Remaining: -- [ ] **§5 static_check stage (emitter)** — the runner/event/filter are built and live-wired - (`447fc7a`); what's missing is a deterministic workflow stage that actually runs - compiler/ktlint/detekt and emits `StaticFindingsRecordedEvent` (`// TODO(wiring)` in - `StaticAnalysisRunner.kt`). Needs an event-emitting stage-type seam. -- [ ] **§6 `CritiqueOutcomeCorrelatedEvent` producer** (= pipeline-addenda A3) — the - `CritiqueFinding` type + `CriticCalibrationProjection` exist (`f783eed`) but nothing feeds - them; needs a reviewer-loop producer that records finding outcomes. Build once, two consumers. +- [ ] **§5 static_check stage — production runner activation only** (seam + emitter built + `e46777e`, see RETRO). The deterministic stage-type seam, `StaticCheckStageExecutor`, the + TOML fields, and the role_pipeline stage now exist; the stage is a **no-op until** a + sandboxed, workspace-scoped `CommandRunner` is wired into the orchestrator AND `static_argv` + is set. That activation (ShellTool-style ProcessBuilder + session cwd + timeout) is + live-QA-gated — left for a focused pass with a real workspace. +- [ ] **§6 reviewer must emit structured findings** (correlator + producer built `e46777e`, see + RETRO). `CritiqueOutcomeCorrelator` + the `CritiqueFindingsRecordedEvent` → outcome → + calibration chain is built and unit-verified; it's **dormant until** the reviewer/plan-critic + actually emit `CritiqueFindingsRecordedEvent`s (a `review_report` findings array + prompt + change). That LLM-side emission is model-dependent → live-QA. - [ ] **§3 symbol grounding** — deferred; needs a real symbol index (file-path grounding done). - [ ] **§2 dynamic plan-derived diff manifest** — allowed files from the `impl_plan` artifact. Future refinement; static per-stage `writes = [globs]` form is shipped. @@ -94,9 +98,10 @@ checkpointing (`1a1b5cc`) → see RETRO. Remaining: - [ ] **A1 activation** (model-dependent → live-QA) — `planner.md` must emit a `brief_echo` block and `role_pipeline.toml` must set `briefEcho="true"` / `briefEchoSource` for the gate to fire live. The gate (`BriefEchoMismatchEvent` + comparator/extractor) is already built. -- [ ] **A3 critique calibration tracking** — the `CriticCalibrationProjection` exists - (`f783eed`); only the `CritiqueOutcomeCorrelatedEvent` producer is missing. Tracked as the - §6 producer follow-up above (build once, two consumers). +- [ ] **A3 critique calibration tracking** — the projection (`f783eed`) AND the producer + (`CritiqueOutcomeCorrelator` + loop-resolution hook, `e46777e`) are now built. Remaining is + only the model-gated reviewer-side emission of `CritiqueFindingsRecordedEvent`s — tracked as + the narrowed §6 item above. ## D. Research workflow spec — `docs/plans/2026-06-13-research-workflow-spec.md` diff --git a/RETRO.md b/RETRO.md index 61182c7d..909a6cf6 100644 --- a/RETRO.md +++ b/RETRO.md @@ -53,6 +53,23 @@ viewer scrolling one line at a time; `db27b34` adds paging. Item moved out of BA --- +## 2026-06-22 — reviewer reliability: static_check stage + critique calibration producer (branch `feat/backlog-burndown`) + +Two deterministic role-reliability tracks whose consumer halves already existed but had nothing +feeding them. Both built + unit-verified (Gradle green, detekt clean); their *activations* are +model-/sandbox-gated and stay narrowed in BACKLOG. + +| Commit | What | Item | +|--------|------|------| +| `e46777e` | **§B-§5 deterministic `static_check` stage seam + emitter.** `StaticCheckStageExecutor` runs a configured tool (`static_tool`/`static_argv` via stage metadata) through the existing `StaticAnalysisRunner` and emits `StaticFindingsRecordedEvent`; `DefaultSessionOrchestrator.enterStage` dispatches any `metadata["stage_type"]=="static_check"` stage to it instead of the LLM subagent and advances on the unconditional exit edge; `TomlWorkflowLoader` gained `stage_type`/`static_tool`/`static_argv` fields; `role_pipeline.toml` carries a `static_check` stage between implementer and reviewer. **No-op until** a sandboxed, workspace-scoped `CommandRunner` is wired + `static_argv` set (live-QA-gated). Tests: `StaticCheckStageExecutorTest` (4) + `StaticCheckStageTest` integration (1). | B §5 | +| `e46777e` | **§B-§6 / A3 critique-outcome producer.** New `CritiqueFindingsRecordedEvent` (+ `CritiqueVerdict`) is the producing side — a critic's findings + verdict per iteration, with `modelHash`. `CritiqueOutcomeCorrelator` (pure, in the reviewer-loop runtime) decides UPHELD / DISMISSED / INCONCLUSIVE per critic and finding id; a hook in `completeWorkflow`/`failWorkflow` correlates recorded findings into `CritiqueOutcomeCorrelatedEvent`s at loop resolution (no-op + idempotent), feeding the existing `:core:critique` calibration projection. **Dormant until** the reviewer/plan-critic emit `CritiqueFindingsRecordedEvent`s (model-gated). Tests: `CritiqueOutcomeCorrelatorTest` (8) + `CritiqueCalibrationWiringTest` integration (1). | B §6 / C-A3 | + +Both follow the same shape as the earlier dormant-activation work: the deterministic seam + the +event wiring land and are unit-proven now; the LLM-/sandbox-side activation is the live-QA-gated +follow-up. `docs/qa/QA-reviewer-static-first.md` updated (no longer "producing stage not built"). + +--- + ## 2026-06-21 — wider grants + revoke (branch `feat/backlog-burndown`) Operator-requested (not a prior BACKLOG item): grants that outlive a single session, plus a diff --git a/docs/qa/QA-reviewer-static-first.md b/docs/qa/QA-reviewer-static-first.md index 3602f0a7..cd096c4b 100644 --- a/docs/qa/QA-reviewer-static-first.md +++ b/docs/qa/QA-reviewer-static-first.md @@ -2,13 +2,13 @@ **Status:** DRAFT (PARTIAL — see gap) **Run date / operator:** -**BACKLOG item:** §B-§5 "reviewer static-first INFRA" — runner + event + context-exclusion `447fc7a`. +**BACKLOG item:** §B-§5 "reviewer static-first" — runner + event + context-exclusion `447fc7a`; deterministic `static_check` stage seam + emitter `e46777e`. --- ## Gap up front (read first) -The exclusion **filter** is live-wired into reviewer context assembly, and `StaticAnalysisRunner` + `StaticFindingsRecordedEvent` are built and unit-tested — but **no stock workflow stage emits `StaticFindingsRecordedEvent` yet** (the deterministic static-check stage-type seam is an open BACKLOG follow-up). So this pass verifies the **filter behavior by injecting the event**; the end-to-end "a stage runs detekt and the reviewer skips those findings" flow is blocked on that seam. +The exclusion **filter** is live-wired into reviewer context assembly; `StaticAnalysisRunner` + `StaticFindingsRecordedEvent` are built; and the deterministic **`static_check` stage seam + emitter now exist** (`e46777e`): the orchestrator runs any `stage_type = "static_check"` stage through `StaticCheckStageExecutor`, and `role_pipeline.toml` carries one between implementer and reviewer. The remaining gap is **activation**: that stage is a **no-op until** a sandboxed, workspace-scoped `CommandRunner` is wired into the orchestrator AND the stage sets `static_argv`. So checks 1–3 verify the **filter by injecting the event** (unchanged); check 4 is now an **activation** check (configure `static_argv` + wire a runner) rather than "blocked on an unbuilt seam". ## Preconditions @@ -29,16 +29,16 @@ The exclusion **filter** is live-wired into reviewer context assembly, and `Stat | 1 | Seed a `StaticFindingsRecordedEvent` for the session with a known finding (e.g. an unused-import lint at `Foo.kt:12`). | The event is in `correx events {id}`. | | | 2 | Drive the implementer→reviewer step so reviewer context is assembled. | The reviewer's input context (server log of the assembled prompt / context pack) **does not** contain a feedback entry restating the seeded static finding — `excludeStaticFindingsFromReview` stripped it. | | | 3 | Seed a finding that does **not** match any retry-feedback entry. | Non-matching entries are untouched; only restatements of recorded static findings are dropped. | | -| 4 | (When the static-check stage seam lands) run a workflow whose static stage emits the event for real. | The reviewer never re-raises the compiler/detekt issues the deterministic tool already caught. _(Deferred — seam not built.)_ | | +| 4 | **Activate** the `static_check` stage: wire a sandboxed `CommandRunner` into the orchestrator and set `static_argv` (e.g. `./gradlew detekt --console=plain`) on the role_pipeline stage, then run a workflow that produces a patch with a real lint/compile issue. | A `StaticFindingsRecordedEvent` with the tool's parsed findings appears in `correx events {id}`, and the reviewer never re-raises those issues. _(Activation is the open §B-§5 follow-up — the seam + emitter are built.)_ | | Evidence sources: server logs of the assembled reviewer context / context pack, `correx events {id}`. ## Out of scope (explicitly NOT covered this pass) -- The **producing** static-check stage (open BACKLOG §B-§5 follow-up: "a stage that emits `StaticFindingsRecordedEvent`"). Check 4 stays deferred until that seam exists. +- **Production runner activation** (open BACKLOG §B-§5 follow-up): wiring a sandboxed, workspace-scoped `CommandRunner` is itself live-QA-gated. The seam + emitter are built (`e46777e`) and unit-verified (`StaticCheckStageExecutorTest`, `StaticCheckStageTest`); check 4 exercises the activation once a runner is wired. - The reviewer prompt half (shipped earlier, `3467826`). ## Disposition -- **PASS (partial)** → record in `RETRO.md` that the **filter** is live-verified (checks 1–3) and keep the §B-§5 *producing-stage* follow-up open in BACKLOG. Do not claim the full static-first loop until check 4 passes. +- **PASS (partial)** → record in `RETRO.md` that the **filter** is live-verified (checks 1–3) and keep the §B-§5 *production-runner activation* follow-up open in BACKLOG. Do not claim the full static-first loop until check 4 passes. - **FAIL** → numbered findings. Status: FAILED until green. diff --git a/docs/qa/README.md b/docs/qa/README.md index 69549325..ba6da693 100644 --- a/docs/qa/README.md +++ b/docs/qa/README.md @@ -15,7 +15,7 @@ to RETRO with the run date + cited evidence; on FAIL → refile each miss as a n | `QA-architect-contradiction.md` | architect contradiction-check (display-only) | `4e08510` `eae0a0c` | model + **llamacpp embedder** + `project.enabled`, 2 sessions/1 process | | `QA-llama-health-probe.md` | LLAMA_SERVER liveness probe | `64a90e7` `9eef936` `266bbf0` | static llama-server you can stop | | `QA-idea-promotion.md` | idea-board → `.correx/project.toml` promotion | `f107ff5` | bound workspace (router model optional — can seed the event) | -| `QA-reviewer-static-first.md` | reviewer static-findings exclusion (PARTIAL — producing stage not built) | `447fc7a` | seed `StaticFindingsRecordedEvent` | +| `QA-reviewer-static-first.md` | reviewer static-findings exclusion (PARTIAL — stage seam+emitter built `e46777e`; runner activation pending) | `447fc7a` `e46777e` | seed `StaticFindingsRecordedEvent` | | `QA-brief-echo-gate.md` | **ARM-IT** plan: brief echo-back gate (off by default; risky to arm blind) | `1df7af5` | prod-candidate model | | `QA-grants.md` | cross-session grants (PROJECT/GLOBAL) + revoke | `c36d41b` `8df0ec7` | model + a T2+ tool call, 2 sessions, 2 workspaces | | `QA-tui-v2.md` | Bubble Tea v2 + Shift+Enter + native cursor + window title | `d247b19` `85af2c6` | **kitty-protocol terminal** + a server with a resumable session | From 790935774719e89b3f7ab52733bd22e59158a48e Mon Sep 17 00:00:00 2001 From: kami Date: Tue, 23 Jun 2026 18:59:38 +0000 Subject: [PATCH 073/107] fix(cli): format stats with Locale.ROOT so output is locale-independent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit renderStats used the default-locale String.format, so under a comma-decimal locale (e.g. ru_RU) percentages/rates rendered as "58,3" — both producing locale-dependent CLI output and failing StatsRenderTest. Format all five numeric rows with Locale.ROOT. Co-Authored-By: Claude Opus 4.8 --- .../kotlin/com/correx/apps/cli/commands/StatsCommand.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/main/kotlin/com/correx/apps/cli/commands/StatsCommand.kt b/apps/cli/src/main/kotlin/com/correx/apps/cli/commands/StatsCommand.kt index 2683febf..adc12fa4 100644 --- a/apps/cli/src/main/kotlin/com/correx/apps/cli/commands/StatsCommand.kt +++ b/apps/cli/src/main/kotlin/com/correx/apps/cli/commands/StatsCommand.kt @@ -13,6 +13,7 @@ import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.request.get import io.ktor.client.statement.bodyAsText import io.ktor.serialization.kotlinx.json.json +import java.util.Locale import kotlinx.coroutines.runBlocking import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json @@ -110,9 +111,10 @@ fun renderStats(report: StatsReportDto): String { lines += " calls: ${report.inferenceCount}" lines += " tokens: ${report.promptTokens} prompt + ${report.completionTokens} completion = " + "${report.promptTokens + report.completionTokens}" - lines += " time: ${report.inferenceMs} ms (%.1f tok/s)".format(report.tokensPerSecond) + lines += " time: ${report.inferenceMs} ms (%.1f tok/s)".format(Locale.ROOT, report.tokensPerSecond) for (p in report.perProvider) { lines += " %-20s %4d calls %6d tok %8d ms (%.1f tok/s)".format( + Locale.ROOT, p.provider, p.completedCount, p.promptTokens + p.completionTokens, p.totalLatencyMs, p.tokensPerSecond, ) } @@ -121,7 +123,7 @@ fun renderStats(report: StatsReportDto): String { lines += "Tools" lines += " calls: ${report.toolCount} time: ${report.toolMs} ms" for (t in report.perTool) { - lines += " %-24s %4d %8d ms".format(t.toolName, t.completedCount, t.totalDurationMs) + lines += " %-24s %4d %8d ms".format(Locale.ROOT, t.toolName, t.completedCount, t.totalDurationMs) } lines += "" @@ -131,6 +133,7 @@ fun renderStats(report: StatsReportDto): String { lines += " total wait: ${report.approvalWaitMs} ms (avg ${report.avgApprovalWaitMs} ms)" for (tier in report.perTier) { lines += " %-4s req %d res %d wait %d ms (avg %d ms)".format( + Locale.ROOT, tier.tier, tier.requestedCount, tier.resolvedCount, tier.totalWaitMs, tier.avgWaitMs, ) } @@ -145,6 +148,7 @@ fun renderStats(report: StatsReportDto): String { lines += "Time accounting (% of session wall time)" lines += " inference: %.1f%% tools: %.1f%% approval-wait: %.1f%% other: %.1f%%".format( + Locale.ROOT, report.inferencePct, report.toolPct, report.approvalWaitPct, otherPct(report), ) From 0c6ac903b7cf6991e7a0badcce6bbba6c4ec4ad0 Mon Sep 17 00:00:00 2001 From: kami Date: Tue, 23 Jun 2026 19:00:58 +0000 Subject: [PATCH 074/107] =?UTF-8?q?feat(tasks):=20native=20task=20tracking?= =?UTF-8?q?=20=E2=80=94=20aggregate,=20agent=20tools,=20context=20bundle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds task tracking as a first-class event-sourced aggregate (ADR-0012). The event log stays the only source of truth; the board is a projection, and all of a project's task events live in one stream (tasks:). core: - new core:tasks module mirroring core:sessions: TaskStatus/State/Reducer/ BoardProjector/CounterProjection/Repository + TaskService write path that appends events via the existing EventStore (no new storage) - task events in core:events (sealed marker TaskEvent), registered in Serialization.kt; status is derived by the reducer from lifecycle facts - work-graph edges: TaskLinkType + typed TaskTargetKind on the link, so bundle resolution dispatches on the recorded kind instead of guessing from the id - delete is a soft tombstone (TaskDeletedEvent); cancel stays a visible outcome surfaces: - agent tools (Tier T2 mutators, T1 read): task_create / task_update / task_delete / task_context, registered in both the main and per-workspace tool registries - REST GET /tasks/{id}/context for external agents context bundle (TaskContextAssembler): - task fields + acceptance criteria + relevant files + resolved dependency tasks (live status) + raw related links + notes, with a compact render() - semantic enrichment via a TaskKnowledgeRetriever port bridged to the existing L3 retriever (late-bound holder for composition-root ordering) - ADR/doc resolution via a TaskDocumentResolver port + filesystem adapter (docs/decisions/adr-*.md, padding-agnostic; *.md paths) Unit-verified across core:tasks / infrastructure:tools / apps:server; full gradlew check green. Not yet live-QA'd end-to-end against a running server. Co-Authored-By: Claude Opus 4.8 --- apps/server/build.gradle | 1 + .../com/correx/apps/server/Application.kt | 2 + .../kotlin/com/correx/apps/server/Main.kt | 20 +++ .../com/correx/apps/server/ServerModule.kt | 4 + .../memory/DeferredTaskKnowledgeRetriever.kt | 18 ++ .../memory/RepoKnowledgeTaskRetriever.kt | 19 ++ .../correx/apps/server/routes/TaskRoutes.kt | 33 ++++ .../server/tasks/FileTaskDocumentResolver.kt | 91 ++++++++++ .../tasks/FileTaskDocumentResolverTest.kt | 72 ++++++++ .../correx/core/events/events/TaskEvents.kt | 142 +++++++++++++++ .../events/serialization/Serialization.kt | 32 ++++ .../correx/core/events/types/IdentityTypes.kt | 4 + .../correx/core/events/types/TaskLinkType.kt | 18 ++ .../core/events/types/TaskNoteAuthor.kt | 11 ++ .../core/events/types/TaskTargetKind.kt | 16 ++ core/tasks/build.gradle | 12 ++ .../correx/core/tasks/DefaultTaskReducer.kt | 147 +++++++++++++++ .../core/tasks/DefaultTaskRepository.kt | 26 +++ .../main/kotlin/com/correx/core/tasks/Task.kt | 8 + .../kotlin/com/correx/core/tasks/TaskBoard.kt | 6 + .../correx/core/tasks/TaskBoardProjector.kt | 22 +++ .../correx/core/tasks/TaskContextAssembler.kt | 109 ++++++++++++ .../correx/core/tasks/TaskContextBundle.kt | 83 +++++++++ .../core/tasks/TaskCounterProjection.kt | 24 +++ .../com/correx/core/tasks/TaskCounterState.kt | 6 + .../correx/core/tasks/TaskDocumentResolver.kt | 21 +++ .../core/tasks/TaskKnowledgeRetriever.kt | 11 ++ .../kotlin/com/correx/core/tasks/TaskLink.kt | 11 ++ .../kotlin/com/correx/core/tasks/TaskNote.kt | 11 ++ .../com/correx/core/tasks/TaskReducer.kt | 10 ++ .../com/correx/core/tasks/TaskService.kt | 166 +++++++++++++++++ .../kotlin/com/correx/core/tasks/TaskState.kt | 26 +++ .../com/correx/core/tasks/TaskStatus.kt | 16 ++ .../com/correx/core/tasks/TaskStreams.kt | 16 ++ .../core/tasks/DefaultTaskReducerTest.kt | 147 +++++++++++++++ .../correx/core/tasks/InMemoryEventStore.kt | 50 ++++++ .../core/tasks/TaskBoardProjectorTest.kt | 74 ++++++++ .../core/tasks/TaskContextAssemblerTest.kt | 128 +++++++++++++ .../core/tasks/TaskCounterProjectionTest.kt | 51 ++++++ .../com/correx/core/tasks/TaskServiceTest.kt | 84 +++++++++ docs/decisions/adr-0012-task-tracking.md | 112 ++++++++++++ .../infrastructure/InfrastructureModule.kt | 8 +- infrastructure/tools/build.gradle | 1 + .../tools/task/TaskContextTool.kt | 56 ++++++ .../tools/task/TaskCreateTool.kt | 89 ++++++++++ .../tools/task/TaskDeleteTool.kt | 55 ++++++ .../infrastructure/tools/task/TaskTools.kt | 26 +++ .../tools/task/TaskUpdateTool.kt | 158 ++++++++++++++++ .../infrastructure/tools/task/ToolParams.kt | 11 ++ .../tools/task/TaskToolsTest.kt | 168 ++++++++++++++++++ settings.gradle | 1 + 51 files changed, 2431 insertions(+), 2 deletions(-) create mode 100644 apps/server/src/main/kotlin/com/correx/apps/server/memory/DeferredTaskKnowledgeRetriever.kt create mode 100644 apps/server/src/main/kotlin/com/correx/apps/server/memory/RepoKnowledgeTaskRetriever.kt create mode 100644 apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt create mode 100644 apps/server/src/main/kotlin/com/correx/apps/server/tasks/FileTaskDocumentResolver.kt create mode 100644 apps/server/src/test/kotlin/com/correx/apps/server/tasks/FileTaskDocumentResolverTest.kt create mode 100644 core/events/src/main/kotlin/com/correx/core/events/events/TaskEvents.kt create mode 100644 core/events/src/main/kotlin/com/correx/core/events/types/TaskLinkType.kt create mode 100644 core/events/src/main/kotlin/com/correx/core/events/types/TaskNoteAuthor.kt create mode 100644 core/events/src/main/kotlin/com/correx/core/events/types/TaskTargetKind.kt create mode 100644 core/tasks/build.gradle create mode 100644 core/tasks/src/main/kotlin/com/correx/core/tasks/DefaultTaskReducer.kt create mode 100644 core/tasks/src/main/kotlin/com/correx/core/tasks/DefaultTaskRepository.kt create mode 100644 core/tasks/src/main/kotlin/com/correx/core/tasks/Task.kt create mode 100644 core/tasks/src/main/kotlin/com/correx/core/tasks/TaskBoard.kt create mode 100644 core/tasks/src/main/kotlin/com/correx/core/tasks/TaskBoardProjector.kt create mode 100644 core/tasks/src/main/kotlin/com/correx/core/tasks/TaskContextAssembler.kt create mode 100644 core/tasks/src/main/kotlin/com/correx/core/tasks/TaskContextBundle.kt create mode 100644 core/tasks/src/main/kotlin/com/correx/core/tasks/TaskCounterProjection.kt create mode 100644 core/tasks/src/main/kotlin/com/correx/core/tasks/TaskCounterState.kt create mode 100644 core/tasks/src/main/kotlin/com/correx/core/tasks/TaskDocumentResolver.kt create mode 100644 core/tasks/src/main/kotlin/com/correx/core/tasks/TaskKnowledgeRetriever.kt create mode 100644 core/tasks/src/main/kotlin/com/correx/core/tasks/TaskLink.kt create mode 100644 core/tasks/src/main/kotlin/com/correx/core/tasks/TaskNote.kt create mode 100644 core/tasks/src/main/kotlin/com/correx/core/tasks/TaskReducer.kt create mode 100644 core/tasks/src/main/kotlin/com/correx/core/tasks/TaskService.kt create mode 100644 core/tasks/src/main/kotlin/com/correx/core/tasks/TaskState.kt create mode 100644 core/tasks/src/main/kotlin/com/correx/core/tasks/TaskStatus.kt create mode 100644 core/tasks/src/main/kotlin/com/correx/core/tasks/TaskStreams.kt create mode 100644 core/tasks/src/test/kotlin/com/correx/core/tasks/DefaultTaskReducerTest.kt create mode 100644 core/tasks/src/test/kotlin/com/correx/core/tasks/InMemoryEventStore.kt create mode 100644 core/tasks/src/test/kotlin/com/correx/core/tasks/TaskBoardProjectorTest.kt create mode 100644 core/tasks/src/test/kotlin/com/correx/core/tasks/TaskContextAssemblerTest.kt create mode 100644 core/tasks/src/test/kotlin/com/correx/core/tasks/TaskCounterProjectionTest.kt create mode 100644 core/tasks/src/test/kotlin/com/correx/core/tasks/TaskServiceTest.kt create mode 100644 docs/decisions/adr-0012-task-tracking.md create mode 100644 infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskContextTool.kt create mode 100644 infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskCreateTool.kt create mode 100644 infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskDeleteTool.kt create mode 100644 infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskTools.kt create mode 100644 infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt create mode 100644 infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/ToolParams.kt create mode 100644 infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt diff --git a/apps/server/build.gradle b/apps/server/build.gradle index aea15c5e..95399e63 100644 --- a/apps/server/build.gradle +++ b/apps/server/build.gradle @@ -20,6 +20,7 @@ dependencies { implementation project(':core:events') implementation project(':core:approvals') implementation project(':core:sessions') + implementation project(':core:tasks') implementation project(':core:kernel') implementation project(':core:journal') implementation project(':core:inference') diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/Application.kt b/apps/server/src/main/kotlin/com/correx/apps/server/Application.kt index 108b8acb..2bdee935 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/Application.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/Application.kt @@ -3,6 +3,7 @@ package com.correx.apps.server import com.correx.apps.server.health.HealthInspectionService import com.correx.apps.server.routes.providerRoutes import com.correx.apps.server.routes.sessionRoutes +import com.correx.apps.server.routes.taskRoutes import com.correx.apps.server.routes.workflowRoutes import com.correx.apps.server.ws.GlobalStreamHandler import io.ktor.http.HttpStatusCode @@ -57,5 +58,6 @@ fun Application.configureServer(module: ServerModule) { sessionRoutes(module) workflowRoutes(module) providerRoutes(module) + taskRoutes(module) } } diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt index e65e3b87..2524047f 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt @@ -47,6 +47,7 @@ import com.correx.core.sessions.DefaultSessionReducer import com.correx.core.sessions.DefaultSessionRepository import com.correx.core.sessions.SessionProjector import com.correx.core.sessions.projections.replay.DefaultEventReplayer +import com.correx.core.tasks.TaskService import com.correx.core.tools.registry.ToolRegistry import com.correx.core.transitions.resolution.DefaultTransitionResolver import com.correx.core.validation.artifact.ArtifactPayloadValidator @@ -80,6 +81,7 @@ import com.correx.infrastructure.inference.commons.UnavailableProbe import com.correx.core.inference.InferenceProvider import com.correx.infrastructure.inference.llama.cpp.LlamaCppInferenceProvider import com.correx.infrastructure.tools.DispatchingToolExecutor +import com.correx.infrastructure.tools.task.TaskTools import com.correx.infrastructure.tools.FileEditConfig import com.correx.infrastructure.tools.FileReadConfig import com.correx.infrastructure.tools.FileWriteConfig @@ -211,6 +213,16 @@ fun main() { null }, ) + // Agents create/update/delete tasks through the tool system (tier-gated like any tool); + // the service appends to the same event log. Project is derived from the task id prefix. + // The knowledge retriever is late-bound: the L3 retriever isn't built until later in this + // composition root, so the holder is captured now and its delegate set once L3 exists (below). + val taskKnowledgeRetriever = com.correx.apps.server.memory.DeferredTaskKnowledgeRetriever() + // Inlines linked ADRs/docs into the context bundle. The repo root is known here, so unlike the + // L3 retriever this needs no late binding. + val taskDocumentResolver = com.correx.apps.server.tasks.FileTaskDocumentResolver(workspaceRoot) + val taskService = TaskService(eventStore) + val taskTools = TaskTools.forService(taskService, taskKnowledgeRetriever, taskDocumentResolver) val toolRegistry = InfrastructureModule.createToolRegistry( buildToolConfig( workspaceRoot, @@ -219,6 +231,7 @@ fun main() { toolsConfig, researchToolConfig, ), + extraTools = taskTools, ) val toolExecutor = InfrastructureModule.createToolExecutor( registry = toolRegistry, @@ -246,6 +259,7 @@ fun main() { val wsToolRegistryProvider = WorkspaceToolRegistryProvider { workspace -> val wsRegistry = InfrastructureModule.createToolRegistry( buildToolConfigForWorkspace(workspace, shellAllowedExecutables, toolsConfig, researchToolConfig), + extraTools = taskTools, ) val wsExecutor = DispatchingToolExecutor(wsRegistry) WorkspaceTools(registry = wsRegistry, executor = wsExecutor) @@ -311,6 +325,10 @@ fun main() { } else { null } + // L3 retriever now exists — bind it into the task context bundle's knowledge port so the + // task_context tool and GET /tasks/{id}/context return semantically-relevant snippets. + taskKnowledgeRetriever.delegate = + repoKnowledgeRetriever?.let { com.correx.apps.server.memory.RepoKnowledgeTaskRetriever(it) } val orchestrator = DefaultSessionOrchestrator( repositories = repositories, engines = engines, @@ -501,6 +519,8 @@ fun main() { operatorProfile = operatorProfile, profileAdaptationService = profileAdaptationService, healthMonitor = healthMonitor, + taskKnowledgeRetriever = taskKnowledgeRetriever, + taskDocumentResolver = taskDocumentResolver, ) // Wire live config editing: persist to TOML, swap the holder, and rebuild config-derived // services. Built after the module so the rebuild hook can swap them in place. diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt b/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt index 5a794926..4293727c 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt @@ -115,6 +115,10 @@ class ServerModule( // Continuous health watch (observability-spec §4). Null disables it (tests / health.enabled=false); // records degraded/restored edge events on the system session, started in [start]. private val healthMonitor: com.correx.apps.server.health.HealthMonitor? = null, + // Semantic enrichment for the task context bundle (GET /tasks/{id}/context). Null disables it. + val taskKnowledgeRetriever: com.correx.core.tasks.TaskKnowledgeRetriever? = null, + // Resolves linked ADRs/docs inline in the task context bundle. Null leaves them as raw links. + val taskDocumentResolver: com.correx.core.tasks.TaskDocumentResolver? = null, ) { val approvalCoordinator: ApprovalCoordinator = approvalCoordinator ?: ApprovalCoordinator( orchestrator = orchestrator, diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/memory/DeferredTaskKnowledgeRetriever.kt b/apps/server/src/main/kotlin/com/correx/apps/server/memory/DeferredTaskKnowledgeRetriever.kt new file mode 100644 index 00000000..25fbc020 --- /dev/null +++ b/apps/server/src/main/kotlin/com/correx/apps/server/memory/DeferredTaskKnowledgeRetriever.kt @@ -0,0 +1,18 @@ +package com.correx.apps.server.memory + +import com.correx.core.tasks.KnowledgeHit +import com.correx.core.tasks.TaskKnowledgeRetriever + +/** + * Late-bound holder for the task knowledge retriever. The task tools are built at tool-registry + * construction time, which is before the L3 retriever exists in the composition root; this holder + * is created early, captured by the tools and the route, and has its [delegate] set once the L3 + * retriever is available. Until then (or when L3 is disabled) it returns no hits. + */ +class DeferredTaskKnowledgeRetriever : TaskKnowledgeRetriever { + @Volatile + var delegate: TaskKnowledgeRetriever? = null + + override suspend fun retrieve(query: String, limit: Int): List = + delegate?.retrieve(query, limit) ?: emptyList() +} diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/memory/RepoKnowledgeTaskRetriever.kt b/apps/server/src/main/kotlin/com/correx/apps/server/memory/RepoKnowledgeTaskRetriever.kt new file mode 100644 index 00000000..232411ec --- /dev/null +++ b/apps/server/src/main/kotlin/com/correx/apps/server/memory/RepoKnowledgeTaskRetriever.kt @@ -0,0 +1,19 @@ +package com.correx.apps.server.memory + +import com.correx.apps.server.health.SYSTEM_SESSION +import com.correx.core.kernel.orchestration.RepoKnowledgeRetriever +import com.correx.core.tasks.KnowledgeHit +import com.correx.core.tasks.TaskKnowledgeRetriever + +/** + * Bridges the task context bundle's [TaskKnowledgeRetriever] port to the existing L3 vector + * retriever ([RepoKnowledgeRetriever], typically [L3RepoKnowledgeRetriever]). Tasks are not + * session-scoped for retrieval, so the system session is used (the L3 repo retriever ignores it). + */ +class RepoKnowledgeTaskRetriever( + private val delegate: RepoKnowledgeRetriever, +) : TaskKnowledgeRetriever { + override suspend fun retrieve(query: String, limit: Int): List = + delegate.retrieve(SYSTEM_SESSION, query, limit) + .map { KnowledgeHit(source = it.path, text = it.text, score = it.score.toDouble()) } +} diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt b/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt new file mode 100644 index 00000000..9e91b282 --- /dev/null +++ b/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt @@ -0,0 +1,33 @@ +package com.correx.apps.server.routes + +import com.correx.apps.server.ServerModule +import com.correx.core.tasks.TaskContextAssembler +import com.correx.core.tasks.TaskService +import com.correx.core.utils.TypeId +import io.ktor.http.HttpStatusCode +import io.ktor.server.response.respond +import io.ktor.server.routing.Route +import io.ktor.server.routing.get +import io.ktor.server.routing.route + +/** + * Read surface for tasks. Exposes the agent context bundle (`GET /tasks/{id}/context`) for + * external agents (Claude Code / aider / codex). In-process roles use the `task_context` tool, + * which shares the same [TaskContextAssembler]. Both read from the event log; nothing is stored. + */ +fun Route.taskRoutes(module: ServerModule) { + val assembler = TaskContextAssembler( + TaskService(module.eventStore), + module.taskKnowledgeRetriever, + module.taskDocumentResolver, + ) + route("/tasks/{id}") { + get("/context") { + val id = call.parameters["id"] + ?: return@get call.respond(HttpStatusCode.BadRequest, "Missing task id") + val bundle = assembler.assemble(TypeId(id)) + ?: return@get call.respond(HttpStatusCode.NotFound, "Task not found") + call.respond(bundle) + } + } +} diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/tasks/FileTaskDocumentResolver.kt b/apps/server/src/main/kotlin/com/correx/apps/server/tasks/FileTaskDocumentResolver.kt new file mode 100644 index 00000000..c64decf3 --- /dev/null +++ b/apps/server/src/main/kotlin/com/correx/apps/server/tasks/FileTaskDocumentResolver.kt @@ -0,0 +1,91 @@ +package com.correx.apps.server.tasks + +import com.correx.core.tasks.ResolvedDocument +import com.correx.core.tasks.TaskDocumentResolver +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.exists +import kotlin.io.path.isDirectory +import kotlin.io.path.isRegularFile +import kotlin.io.path.name +import kotlin.io.path.readText + +private const val EXCERPT_MAX = 280 +private val ADR_ID = Regex("(?i)^adr[-_ ]?0*(\\d+)$") +private val ADR_FILE = Regex("^adr-0*(\\d+)") +private val FRONTMATTER_NAME = Regex("(?m)^name:\\s*\"?(.+?)\"?\\s*$") +private val FRONTMATTER_DESC = Regex("(?m)^description:\\s*\"?(.+?)\"?\\s*$") + +/** + * Resolves link targets to repo documents for the task context bundle: + * - ADR ids ("adr-7", "ADR-0007", "adr 12") → `docs/decisions/adr--*.md` (matched by number, + * padding-agnostic); + * - explicit "*.md" paths under the repo root. + * Title is the frontmatter `name:` or first `# ` heading; excerpt is the frontmatter + * `description:` or the first prose paragraph, capped. Returns null for anything else. + */ +class FileTaskDocumentResolver( + private val repoRoot: Path, + private val decisionsDir: Path = repoRoot.resolve("docs/decisions"), +) : TaskDocumentResolver { + + override suspend fun resolve(targetId: String): ResolvedDocument? = withContext(Dispatchers.IO) { + resolveAdr(targetId.trim()) ?: resolveMarkdownPath(targetId.trim()) + } + + private fun resolveAdr(targetId: String): ResolvedDocument? { + val num = ADR_ID.matchEntire(targetId)?.groupValues?.get(1)?.toIntOrNull() ?: return null + if (!decisionsDir.isDirectory()) return null + val file = Files.list(decisionsDir).use { stream -> + stream.filter { it.isRegularFile() && it.name.endsWith(".md") } + .filter { adrNumber(it.name) == num } + .findFirst() + .orElse(null) + } ?: return null + return readDocument(file) + } + + private fun resolveMarkdownPath(targetId: String): ResolvedDocument? { + if (!targetId.endsWith(".md")) return null + val candidate = repoRoot.resolve(targetId).normalize() + if (!candidate.startsWith(repoRoot) || !candidate.exists() || !candidate.isRegularFile()) return null + return readDocument(candidate) + } + + private fun adrNumber(fileName: String): Int? = + ADR_FILE.find(fileName.lowercase())?.groupValues?.get(1)?.toIntOrNull() + + private fun readDocument(file: Path): ResolvedDocument { + val text = runCatching { file.readText() }.getOrDefault("") + return ResolvedDocument( + title = extractTitle(text) ?: file.name, + path = repoRoot.relativize(file).toString(), + excerpt = extractExcerpt(text), + ) + } + + private fun extractTitle(text: String): String? { + FRONTMATTER_NAME.find(text)?.groupValues?.get(1)?.trim()?.takeIf { it.isNotBlank() }?.let { return it } + return text.lineSequence().firstOrNull { it.startsWith("# ") }?.removePrefix("# ")?.trim() + } + + private fun extractExcerpt(text: String): String { + val desc = FRONTMATTER_DESC.find(text)?.groupValues?.get(1)?.trim() + return (desc ?: firstParagraph(text)).take(EXCERPT_MAX).trim() + } + + private fun firstParagraph(text: String): String = + stripFrontmatter(text).lineSequence() + .map { it.trim() } + .firstOrNull { it.isNotEmpty() && !it.startsWith("#") && !it.startsWith("---") } + ?: "" + + private fun stripFrontmatter(text: String): String { + if (!text.startsWith("---")) return text + val lines = text.lines() + val closeIdx = lines.drop(1).indexOfFirst { it.trim() == "---" } + return if (closeIdx >= 0) lines.drop(closeIdx + 2).joinToString("\n") else text + } +} diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/tasks/FileTaskDocumentResolverTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/tasks/FileTaskDocumentResolverTest.kt new file mode 100644 index 00000000..fc4a3728 --- /dev/null +++ b/apps/server/src/test/kotlin/com/correx/apps/server/tasks/FileTaskDocumentResolverTest.kt @@ -0,0 +1,72 @@ +package com.correx.apps.server.tasks + +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Path +import kotlin.io.path.createDirectories +import kotlin.io.path.writeText + +class FileTaskDocumentResolverTest { + + @TempDir + lateinit var repo: Path + + private fun writeAdr() { + val dir = repo.resolve("docs/decisions").also { it.createDirectories() } + dir.resolve("adr-0007-redis.md").writeText( + """ + --- + name: "ADR 7 Redis Sessions" + description: "use redis for session storage" + --- + + # ADR 7: use redis for session storage + + **status:** accepted + + ## decision + Redis backs session storage. + """.trimIndent(), + ) + } + + @Test + fun `resolves an ADR id padding-agnostically`() = runBlocking { + writeAdr() + val resolver = FileTaskDocumentResolver(repo) + + val byShort = resolver.resolve("adr-7") + val byPadded = resolver.resolve("ADR-0007") + + assertEquals("ADR 7 Redis Sessions", byShort?.title) + assertEquals("use redis for session storage", byShort?.excerpt) + assertEquals("docs/decisions/adr-0007-redis.md", byShort?.path) + assertEquals(byShort, byPadded) + } + + @Test + fun `unknown ADR resolves to null`() = runBlocking { + writeAdr() + assertNull(FileTaskDocumentResolver(repo).resolve("adr-999")) + } + + @Test + fun `resolves an explicit markdown path and falls back to heading and first paragraph`() = runBlocking { + repo.resolve("docs/notes").createDirectories() + repo.resolve("docs/notes/plan.md").writeText("# Plan\n\nThe plan body.\n") + + val doc = FileTaskDocumentResolver(repo).resolve("docs/notes/plan.md") + + assertEquals("Plan", doc?.title) + assertEquals("The plan body.", doc?.excerpt) + assertEquals("docs/notes/plan.md", doc?.path) + } + + @Test + fun `non-document target resolves to null`() = runBlocking { + assertNull(FileTaskDocumentResolver(repo).resolve("ext-123")) + } +} diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/TaskEvents.kt b/core/events/src/main/kotlin/com/correx/core/events/events/TaskEvents.kt new file mode 100644 index 00000000..dac33e83 --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/events/TaskEvents.kt @@ -0,0 +1,142 @@ +package com.correx.core.events.events + +import com.correx.core.events.types.ProjectId +import com.correx.core.events.types.TaskId +import com.correx.core.events.types.TaskLinkType +import com.correx.core.events.types.TaskNoteAuthor +import com.correx.core.events.types.TaskTargetKind +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Marker over every task-lifecycle event. Deliberately *not* part of the serialized + * [EventPayload] hierarchy — concrete events implement both interfaces — so projections + * can recover the owning [taskId] from any payload via `payload as? TaskEvent` without a + * giant `when`. Status is never carried on the wire: it is derived by the task reducer + * from these facts (mirroring how session status is derived from stage events). + */ +sealed interface TaskEvent { + val taskId: TaskId +} + +@Serializable +@SerialName("TaskCreated") +data class TaskCreatedEvent( + override val taskId: TaskId, + val projectId: ProjectId, + val key: String, + val title: String, + val goal: String, + val acceptanceCriteria: List = emptyList(), + val affectedPaths: List = emptyList(), +) : EventPayload, TaskEvent + +@Serializable +@SerialName("TaskClaimed") +data class TaskClaimedEvent( + override val taskId: TaskId, + val claimant: String, +) : EventPayload, TaskEvent + +@Serializable +@SerialName("TaskReleased") +data class TaskReleasedEvent( + override val taskId: TaskId, +) : EventPayload, TaskEvent + +@Serializable +@SerialName("TaskBlocked") +data class TaskBlockedEvent( + override val taskId: TaskId, + val reason: String, +) : EventPayload, TaskEvent + +@Serializable +@SerialName("TaskUnblocked") +data class TaskUnblockedEvent( + override val taskId: TaskId, +) : EventPayload, TaskEvent + +@Serializable +@SerialName("TaskSubmittedForReview") +data class TaskSubmittedForReviewEvent( + override val taskId: TaskId, +) : EventPayload, TaskEvent + +@Serializable +@SerialName("TaskCompleted") +data class TaskCompletedEvent( + override val taskId: TaskId, +) : EventPayload, TaskEvent + +@Serializable +@SerialName("TaskReopened") +data class TaskReopenedEvent( + override val taskId: TaskId, +) : EventPayload, TaskEvent + +@Serializable +@SerialName("TaskCancelled") +data class TaskCancelledEvent( + override val taskId: TaskId, + val reason: String? = null, +) : EventPayload, TaskEvent + +@Serializable +@SerialName("TaskEdited") +data class TaskEditedEvent( + override val taskId: TaskId, + val title: String? = null, + val goal: String? = null, +) : EventPayload, TaskEvent + +@Serializable +@SerialName("TaskAcceptanceCriteriaSet") +data class TaskAcceptanceCriteriaSetEvent( + override val taskId: TaskId, + val criteria: List, +) : EventPayload, TaskEvent + +@Serializable +@SerialName("TaskAffectedPathsSet") +data class TaskAffectedPathsSetEvent( + override val taskId: TaskId, + val paths: List, +) : EventPayload, TaskEvent + +@Serializable +@SerialName("TaskLinked") +data class TaskLinkedEvent( + override val taskId: TaskId, + val targetId: String, + // `linkType`, not `type`: the JSON polymorphic class discriminator is "type". + @SerialName("linkType") val type: TaskLinkType, + val targetKind: TaskTargetKind, +) : EventPayload, TaskEvent + +@Serializable +@SerialName("TaskUnlinked") +data class TaskUnlinkedEvent( + override val taskId: TaskId, + val targetId: String, + @SerialName("linkType") val type: TaskLinkType, +) : EventPayload, TaskEvent + +@Serializable +@SerialName("TaskNoteAdded") +data class TaskNoteAddedEvent( + override val taskId: TaskId, + val author: TaskNoteAuthor, + val body: String, +) : EventPayload, TaskEvent + +/** + * Soft-delete tombstone. The event stays in the log (history is never rewritten); the board + * projection drops the task from active views. Distinct from cancellation, which is a + * lifecycle outcome that stays visible as CANCELLED. + */ +@Serializable +@SerialName("TaskDeleted") +data class TaskDeletedEvent( + override val taskId: TaskId, +) : EventPayload, TaskEvent diff --git a/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt b/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt index 533032f2..76667f4d 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt @@ -72,6 +72,22 @@ import com.correx.core.events.events.WorkflowCompletedEvent import com.correx.core.events.events.WorkflowFailedEvent import com.correx.core.events.events.WorkflowProposedEvent import com.correx.core.events.events.WorkflowStartedEvent +import com.correx.core.events.events.TaskCreatedEvent +import com.correx.core.events.events.TaskClaimedEvent +import com.correx.core.events.events.TaskReleasedEvent +import com.correx.core.events.events.TaskBlockedEvent +import com.correx.core.events.events.TaskUnblockedEvent +import com.correx.core.events.events.TaskSubmittedForReviewEvent +import com.correx.core.events.events.TaskCompletedEvent +import com.correx.core.events.events.TaskReopenedEvent +import com.correx.core.events.events.TaskCancelledEvent +import com.correx.core.events.events.TaskEditedEvent +import com.correx.core.events.events.TaskAcceptanceCriteriaSetEvent +import com.correx.core.events.events.TaskAffectedPathsSetEvent +import com.correx.core.events.events.TaskLinkedEvent +import com.correx.core.events.events.TaskUnlinkedEvent +import com.correx.core.events.events.TaskNoteAddedEvent +import com.correx.core.events.events.TaskDeletedEvent import kotlinx.serialization.json.Json import kotlinx.serialization.modules.SerializersModule import kotlinx.serialization.modules.polymorphic @@ -151,6 +167,22 @@ val eventModule = SerializersModule { subclass(LowQualityExtractionEvent::class) subclass(EgressHostsGrantedEvent::class) subclass(StaticFindingsRecordedEvent::class) + subclass(TaskCreatedEvent::class) + subclass(TaskClaimedEvent::class) + subclass(TaskReleasedEvent::class) + subclass(TaskBlockedEvent::class) + subclass(TaskUnblockedEvent::class) + subclass(TaskSubmittedForReviewEvent::class) + subclass(TaskCompletedEvent::class) + subclass(TaskReopenedEvent::class) + subclass(TaskCancelledEvent::class) + subclass(TaskEditedEvent::class) + subclass(TaskAcceptanceCriteriaSetEvent::class) + subclass(TaskAffectedPathsSetEvent::class) + subclass(TaskLinkedEvent::class) + subclass(TaskUnlinkedEvent::class) + subclass(TaskNoteAddedEvent::class) + subclass(TaskDeletedEvent::class) } } diff --git a/core/events/src/main/kotlin/com/correx/core/events/types/IdentityTypes.kt b/core/events/src/main/kotlin/com/correx/core/events/types/IdentityTypes.kt index dcc3e58d..d40471ec 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/types/IdentityTypes.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/types/IdentityTypes.kt @@ -19,6 +19,10 @@ typealias TransitionId = TypeId typealias ArtifactId = TypeId +// Tasks types +typealias TaskId = TypeId + + // Context types typealias ContextPackId = TypeId typealias ContextEntryId = TypeId diff --git a/core/events/src/main/kotlin/com/correx/core/events/types/TaskLinkType.kt b/core/events/src/main/kotlin/com/correx/core/events/types/TaskLinkType.kt new file mode 100644 index 00000000..f1981591 --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/types/TaskLinkType.kt @@ -0,0 +1,18 @@ +package com.correx.core.events.types + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Edge types for the work graph. A task links to other tasks, artifacts (by [ArtifactId]), + * or sessions (by [SessionId]) — the target is stored as a plain id string plus a type. + */ +@Serializable +enum class TaskLinkType { + @SerialName("DEPENDS_ON") DEPENDS_ON, + @SerialName("BLOCKS") BLOCKS, + @SerialName("IMPLEMENTS") IMPLEMENTS, + @SerialName("RELATES_TO") RELATES_TO, + @SerialName("PRODUCED") PRODUCED, + @SerialName("CONTEXT") CONTEXT, +} diff --git a/core/events/src/main/kotlin/com/correx/core/events/types/TaskNoteAuthor.kt b/core/events/src/main/kotlin/com/correx/core/events/types/TaskNoteAuthor.kt new file mode 100644 index 00000000..7f2fd37c --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/types/TaskNoteAuthor.kt @@ -0,0 +1,11 @@ +package com.correx.core.events.types + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** Who authored a task note — used to keep the agent and human lanes separable. */ +@Serializable +enum class TaskNoteAuthor { + @SerialName("AGENT") AGENT, + @SerialName("HUMAN") HUMAN, +} diff --git a/core/events/src/main/kotlin/com/correx/core/events/types/TaskTargetKind.kt b/core/events/src/main/kotlin/com/correx/core/events/types/TaskTargetKind.kt new file mode 100644 index 00000000..e57cf254 --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/types/TaskTargetKind.kt @@ -0,0 +1,16 @@ +package com.correx.core.events.types + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * What a task link points at — recorded on the link so the context bundle dispatches resolution + * deterministically (task lookup vs doc resolution vs left-raw) instead of inferring from the id. + */ +@Serializable +enum class TaskTargetKind { + @SerialName("TASK") TASK, + @SerialName("DOC") DOC, + @SerialName("ARTIFACT") ARTIFACT, + @SerialName("SESSION") SESSION, +} diff --git a/core/tasks/build.gradle b/core/tasks/build.gradle new file mode 100644 index 00000000..9fb5488c --- /dev/null +++ b/core/tasks/build.gradle @@ -0,0 +1,12 @@ +plugins { + id 'java-library' + id 'org.jetbrains.kotlin.jvm' + id 'org.jetbrains.kotlin.plugin.serialization' +} + +dependencies { + implementation(project(":core:events")) + testImplementation(testFixtures(project(":testing:contracts"))) +} + +tasks.named("koverVerify").configure { enabled = false } diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/DefaultTaskReducer.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/DefaultTaskReducer.kt new file mode 100644 index 00000000..4591db35 --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/DefaultTaskReducer.kt @@ -0,0 +1,147 @@ +package com.correx.core.tasks + +import com.correx.core.events.events.StoredEvent +import com.correx.core.events.events.TaskAcceptanceCriteriaSetEvent +import com.correx.core.events.events.TaskAffectedPathsSetEvent +import com.correx.core.events.events.TaskBlockedEvent +import com.correx.core.events.events.TaskCancelledEvent +import com.correx.core.events.events.TaskClaimedEvent +import com.correx.core.events.events.TaskCompletedEvent +import com.correx.core.events.events.TaskCreatedEvent +import com.correx.core.events.events.TaskDeletedEvent +import com.correx.core.events.events.TaskEditedEvent +import com.correx.core.events.events.TaskEvent +import com.correx.core.events.events.TaskLinkedEvent +import com.correx.core.events.events.TaskNoteAddedEvent +import com.correx.core.events.events.TaskReleasedEvent +import com.correx.core.events.events.TaskReopenedEvent +import com.correx.core.events.events.TaskSubmittedForReviewEvent +import com.correx.core.events.events.TaskUnblockedEvent +import com.correx.core.events.events.TaskUnlinkedEvent +import kotlinx.datetime.Instant + +/** + * Folds task events into [TaskState]. Field-update events always apply; lifecycle events + * apply only on a legal transition — an illegal transition is ignored and counted in + * [TaskState.invalidTransitions] (mirrors how `SessionState` tracks invalid transitions). + */ +class DefaultTaskReducer : TaskReducer { + + override fun reduce(state: TaskState, event: StoredEvent): TaskState { + val payload = event.payload as? TaskEvent ?: return state + val ts = event.metadata.timestamp + val base = state.copy( + createdAt = state.createdAt ?: ts, + updatedAt = ts, + ) + return reduceContent(state, base, payload, ts) + ?: reduceTransition(state, base, payload) + } + + /** Field updates that do not touch status. Returns null for lifecycle events. */ + private fun reduceContent( + state: TaskState, + base: TaskState, + payload: TaskEvent, + ts: Instant, + ): TaskState? = when (payload) { + is TaskCreatedEvent -> base.copy( + status = TaskStatus.TODO, + key = payload.key, + projectId = payload.projectId, + title = payload.title, + goal = payload.goal, + acceptanceCriteria = payload.acceptanceCriteria, + affectedPaths = payload.affectedPaths, + ) + + is TaskEditedEvent -> base.copy( + title = payload.title ?: state.title, + goal = payload.goal ?: state.goal, + ) + + is TaskAcceptanceCriteriaSetEvent -> base.copy(acceptanceCriteria = payload.criteria) + + is TaskAffectedPathsSetEvent -> base.copy(affectedPaths = payload.paths) + + is TaskLinkedEvent -> base.copy( + links = (state.links + TaskLink(payload.targetId, payload.type, payload.targetKind)).distinct(), + ) + + is TaskUnlinkedEvent -> base.copy( + links = state.links.filterNot { + it.targetId == payload.targetId && it.type == payload.type + }, + ) + + is TaskNoteAddedEvent -> base.copy( + notes = state.notes + TaskNote(payload.author, payload.body, ts), + ) + + is TaskDeletedEvent -> base.copy(deleted = true) + + else -> null + } + + /** Lifecycle events: apply on a legal transition, else count it invalid. */ + private fun reduceTransition( + state: TaskState, + base: TaskState, + payload: TaskEvent, + ): TaskState { + val from = state.status + return when (payload) { + is TaskClaimedEvent -> + base.transitionTo(TaskStatus.IN_PROGRESS, from == TaskStatus.TODO) { + it.copy(claimant = payload.claimant) + } + + is TaskReleasedEvent -> + base.transitionTo(TaskStatus.TODO, from == TaskStatus.IN_PROGRESS) { + it.copy(claimant = null) + } + + is TaskBlockedEvent -> + base.transitionTo(TaskStatus.BLOCKED, from in WORKING) + + is TaskUnblockedEvent -> + base.transitionTo( + if (state.claimant != null) TaskStatus.IN_PROGRESS else TaskStatus.TODO, + from == TaskStatus.BLOCKED, + ) + + is TaskSubmittedForReviewEvent -> + base.transitionTo(TaskStatus.IN_REVIEW, from == TaskStatus.IN_PROGRESS) + + is TaskCompletedEvent -> + base.transitionTo(TaskStatus.DONE, from == TaskStatus.IN_REVIEW) + + is TaskReopenedEvent -> + base.transitionTo(TaskStatus.TODO, from in TERMINAL) { + it.copy(claimant = null) + } + + is TaskCancelledEvent -> + base.transitionTo(TaskStatus.CANCELLED, from in CANCELLABLE) + + else -> state + } ?: state.copy(invalidTransitions = state.invalidTransitions + 1) + } + + private inline fun TaskState.transitionTo( + to: TaskStatus, + legal: Boolean, + mutate: (TaskState) -> TaskState = { it }, + ): TaskState? = if (legal) mutate(copy(status = to)) else null + + private companion object { + val WORKING = setOf(TaskStatus.TODO, TaskStatus.IN_PROGRESS, TaskStatus.IN_REVIEW) + val TERMINAL = setOf(TaskStatus.DONE, TaskStatus.CANCELLED) + val CANCELLABLE = setOf( + TaskStatus.TODO, + TaskStatus.IN_PROGRESS, + TaskStatus.IN_REVIEW, + TaskStatus.BLOCKED, + ) + } +} diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/DefaultTaskRepository.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/DefaultTaskRepository.kt new file mode 100644 index 00000000..830caf82 --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/DefaultTaskRepository.kt @@ -0,0 +1,26 @@ +package com.correx.core.tasks + +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.TaskId +import com.correx.core.sessions.projections.replay.EventReplayer + +/** + * Reads a project's task board by replaying its stream. Mirrors `DefaultSessionRepository`: + * the repository is a thin read model over an [EventReplayer]; the event log is the source + * of truth and the board is rebuilt from it. + */ +class DefaultTaskRepository( + private val replayer: EventReplayer, + private val streamId: SessionId, +) { + + fun board(): TaskBoard = replayer.rebuild(streamId) + + fun getTask(taskId: TaskId): Task? = + board()[taskId]?.takeUnless { it.deleted }?.let { Task(taskId, it) } + + fun list(): List = + board().filterValues { !it.deleted }.map { (id, state) -> Task(id, state) } + + fun count(): Int = list().size +} diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/Task.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/Task.kt new file mode 100644 index 00000000..41b246ed --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/Task.kt @@ -0,0 +1,8 @@ +package com.correx.core.tasks + +import com.correx.core.events.types.TaskId + +data class Task( + val taskId: TaskId, + val state: TaskState, +) diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskBoard.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskBoard.kt new file mode 100644 index 00000000..2c47929b --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskBoard.kt @@ -0,0 +1,6 @@ +package com.correx.core.tasks + +import com.correx.core.events.types.TaskId + +/** All tasks in a project stream, keyed by id — the unit a single replay produces. */ +typealias TaskBoard = Map diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskBoardProjector.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskBoardProjector.kt new file mode 100644 index 00000000..569d78ff --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskBoardProjector.kt @@ -0,0 +1,22 @@ +package com.correx.core.tasks + +import com.correx.core.events.events.StoredEvent +import com.correx.core.events.events.TaskEvent +import com.correx.core.sessions.projections.Projection + +/** + * Folds a per-project task stream into a [TaskBoard]. Each event is routed to its owning + * task by [TaskEvent.taskId]; non-task payloads leave the board untouched. + */ +class TaskBoardProjector( + private val reducer: TaskReducer, +) : Projection { + + override fun initial(): TaskBoard = emptyMap() + + override fun apply(state: TaskBoard, event: StoredEvent): TaskBoard { + val taskId = (event.payload as? TaskEvent)?.taskId ?: return state + val current = state[taskId] ?: TaskState() + return state + (taskId to reducer.reduce(current, event)) + } +} diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskContextAssembler.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskContextAssembler.kt new file mode 100644 index 00000000..3ff9fac6 --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskContextAssembler.kt @@ -0,0 +1,109 @@ +package com.correx.core.tasks + +import com.correx.core.events.types.TaskId +import com.correx.core.events.types.TaskTargetKind + +/** + * Assembles a [TaskContextBundle] for a task. Links are partitioned by their recorded + * [com.correx.core.events.types.TaskTargetKind]: TASK targets are resolved to [RelatedTask] + * dependencies (with live status), DOC targets are resolved inline via [documentResolver], and + * anything unresolved (or ARTIFACT/SESSION, not inlined yet) becomes a raw [RelatedLink]. + * + * When a [TaskKnowledgeRetriever] is supplied, the bundle is additionally enriched with + * semantically-relevant snippets over the task's title+goal. Retrieval and doc-resolution failures + * are swallowed (the bundle still returns) so context assembly never fails on a flaky dependency. + */ +class TaskContextAssembler( + private val service: TaskService, + private val retriever: TaskKnowledgeRetriever? = null, + private val documentResolver: TaskDocumentResolver? = null, + private val knowledgeLimit: Int = DEFAULT_KNOWLEDGE_LIMIT, +) { + + suspend fun assemble(taskId: TaskId): TaskContextBundle? { + val state = service.getTask(taskId)?.state ?: return null + + val dependencies = mutableListOf() + val documents = mutableListOf() + val related = mutableListOf() + for (link in state.links) { + // Resolution dispatches on the link's recorded targetKind — no guessing from the id. + // A kind whose resolution comes up empty (missing task, unresolvable doc, or an + // ARTIFACT/SESSION we don't inline yet) falls back to a raw related link. + when (link.targetKind) { + TaskTargetKind.TASK -> addTask(link, dependencies, related) + TaskTargetKind.DOC -> addDocument(link, documents, related) + TaskTargetKind.ARTIFACT, TaskTargetKind.SESSION -> + related += RelatedLink(link.targetId, link.type.name) + } + } + + return TaskContextBundle( + id = taskId.value, + key = state.key, + status = state.status.name, + title = state.title, + goal = state.goal, + acceptanceCriteria = state.acceptanceCriteria, + relevantFiles = state.affectedPaths, + dependencies = dependencies, + documents = documents, + relatedLinks = related, + notes = state.notes.map { NoteEntry(it.author.name, it.body) }, + relevantKnowledge = retrieveKnowledge(state), + ) + } + + private fun addTask( + link: TaskLink, + dependencies: MutableList, + related: MutableList, + ) { + val linked = runCatching { service.getTask(TaskId(link.targetId)) }.getOrNull() + if (linked != null) { + dependencies += RelatedTask( + id = linked.taskId.value, + status = linked.state.status.name, + title = linked.state.title, + link = link.type.name, + ) + } else { + related += RelatedLink(link.targetId, link.type.name) + } + } + + private suspend fun addDocument( + link: TaskLink, + documents: MutableList, + related: MutableList, + ) { + val doc = resolveDocument(link.targetId) + if (doc != null) { + documents += TaskDocument( + targetId = link.targetId, + type = link.type.name, + title = doc.title, + path = doc.path, + excerpt = doc.excerpt, + ) + } else { + related += RelatedLink(link.targetId, link.type.name) + } + } + + private suspend fun resolveDocument(targetId: String): ResolvedDocument? { + val resolver = documentResolver ?: return null + return runCatching { resolver.resolve(targetId) }.getOrNull() + } + + private suspend fun retrieveKnowledge(state: TaskState): List { + val active = retriever ?: return emptyList() + val query = listOfNotNull(state.title, state.goal).joinToString(" ").trim() + if (query.isEmpty()) return emptyList() + return runCatching { active.retrieve(query, knowledgeLimit) }.getOrDefault(emptyList()) + } + + private companion object { + const val DEFAULT_KNOWLEDGE_LIMIT = 5 + } +} diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskContextBundle.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskContextBundle.kt new file mode 100644 index 00000000..65d36c3c --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskContextBundle.kt @@ -0,0 +1,83 @@ +package com.correx.core.tasks + +import kotlinx.serialization.Serializable + +/** + * Token-optimized context bundle for a task — the single payload an agent needs to start work: + * the task itself, its acceptance criteria, resolved dependency tasks, the related (non-task) + * links it should fetch, the relevant files, and notes. Assembled by [TaskContextAssembler]. + */ +@Serializable +data class TaskContextBundle( + val id: String, + val key: String?, + val status: String, + val title: String?, + val goal: String?, + val acceptanceCriteria: List, + val relevantFiles: List, + val dependencies: List, + val documents: List, + val relatedLinks: List, + val notes: List, + val relevantKnowledge: List = emptyList(), +) { + /** Compact, label-per-line rendering for tool output (cheaper than JSON for the model). */ + fun render(): String = buildString { + appendLine("task $id [$status] ${title.orEmpty()}".trimEnd()) + goal?.let { appendLine("goal: $it") } + appendList("acceptance_criteria", acceptanceCriteria) { " - $it" } + if (relevantFiles.isNotEmpty()) appendLine("relevant_files: ${relevantFiles.joinToString(", ")}") + appendList("dependencies", dependencies) { " - ${it.id} [${it.status}] ${it.title.orEmpty()} (${it.link})".trimEnd() } + appendList("documents", documents) { " - ${it.targetId} (${it.type}) ${it.title} [${it.path}]: ${it.excerpt}" } + appendList("related", relatedLinks) { " - ${it.targetId} (${it.type})" } + appendList("relevant_knowledge", relevantKnowledge) { " - ${it.source}: ${it.text}" } + appendList("notes", notes) { " - [${it.author}] ${it.body}" } + }.trimEnd() + + private fun StringBuilder.appendList(label: String, items: List, line: (T) -> String) { + if (items.isEmpty()) return + appendLine("$label:") + items.forEach { appendLine(line(it)) } + } +} + +/** A linked task resolved against the board: enough for the agent to judge readiness. */ +@Serializable +data class RelatedTask( + val id: String, + val status: String, + val title: String?, + val link: String, +) + +/** A link whose target resolved to a document (ADR/doc): title + excerpt inlined for the agent. */ +@Serializable +data class TaskDocument( + val targetId: String, + val type: String, + val title: String, + val path: String, + val excerpt: String, +) + +/** A link whose target is neither a task nor a resolvable doc — left for the agent to fetch. */ +@Serializable +data class RelatedLink( + val targetId: String, + val type: String, +) + +@Serializable +data class NoteEntry( + val author: String, + val body: String, +) + +/** A semantically-retrieved snippet relevant to the task's goal (e.g. a repo file or doc). */ +@Serializable +data class KnowledgeHit( + val source: String, + val text: String, + val score: Double, +) diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskCounterProjection.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskCounterProjection.kt new file mode 100644 index 00000000..ddc32ac1 --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskCounterProjection.kt @@ -0,0 +1,24 @@ +package com.correx.core.tasks + +import com.correx.core.events.events.StoredEvent +import com.correx.core.events.events.TaskCreatedEvent +import com.correx.core.sessions.projections.Projection + +/** + * Counts created tasks in a project stream. The numeric suffix of a human key + * (`-`) is taken from this count, so ids never collide even after a task is + * cancelled — it counts creations, not currently-live tasks. + */ +class TaskCounterProjection( + private val projectId: String, +) : Projection { + + override fun initial(): TaskCounterState = + TaskCounterState(projectId, 0) + + override fun apply( + state: TaskCounterState, + event: StoredEvent, + ): TaskCounterState = + if (event.payload is TaskCreatedEvent) state.copy(count = state.count + 1) else state +} diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskCounterState.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskCounterState.kt new file mode 100644 index 00000000..5a6ed45b --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskCounterState.kt @@ -0,0 +1,6 @@ +package com.correx.core.tasks + +data class TaskCounterState( + val projectId: String, + val count: Int, +) diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskDocumentResolver.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskDocumentResolver.kt new file mode 100644 index 00000000..9c3dde9a --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskDocumentResolver.kt @@ -0,0 +1,21 @@ +package com.correx.core.tasks + +import kotlinx.serialization.Serializable + +/** + * Port for resolving a link target (e.g. "adr-7" or a "docs/…/x.md" path) to an inline document + * summary, so the context bundle can carry the doc's title + excerpt instead of a bare id the + * agent has to fetch separately. Implemented in a higher layer (apps/server reads the repo's + * docs); `core:tasks` stays decoupled from the filesystem. Returns null when the target is not a + * resolvable document — the link then stays a raw related link. + */ +interface TaskDocumentResolver { + suspend fun resolve(targetId: String): ResolvedDocument? +} + +@Serializable +data class ResolvedDocument( + val title: String, + val path: String, + val excerpt: String, +) diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskKnowledgeRetriever.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskKnowledgeRetriever.kt new file mode 100644 index 00000000..5d2593be --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskKnowledgeRetriever.kt @@ -0,0 +1,11 @@ +package com.correx.core.tasks + +/** + * Port for semantic enrichment of a task context bundle. Implemented in a higher layer + * (apps/server bridges it to the L3 vector retriever) and injected into [TaskContextAssembler]; + * `core:tasks` stays decoupled from the retrieval stack. Implementations must be side-effect-free + * and tolerate being called with an arbitrary natural-language query. + */ +interface TaskKnowledgeRetriever { + suspend fun retrieve(query: String, limit: Int): List +} diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskLink.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskLink.kt new file mode 100644 index 00000000..b7cfb582 --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskLink.kt @@ -0,0 +1,11 @@ +package com.correx.core.tasks + +import com.correx.core.events.types.TaskLinkType +import com.correx.core.events.types.TaskTargetKind + +/** One edge in the work graph: a typed pointer from a task to another entity, tagged by kind. */ +data class TaskLink( + val targetId: String, + val type: TaskLinkType, + val targetKind: TaskTargetKind, +) diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskNote.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskNote.kt new file mode 100644 index 00000000..091ee7de --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskNote.kt @@ -0,0 +1,11 @@ +package com.correx.core.tasks + +import com.correx.core.events.types.TaskNoteAuthor +import kotlinx.datetime.Instant + +/** A note on a task, kept in agent/human lanes so consumers can filter by author. */ +data class TaskNote( + val author: TaskNoteAuthor, + val body: String, + val at: Instant, +) diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskReducer.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskReducer.kt new file mode 100644 index 00000000..48c2c44d --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskReducer.kt @@ -0,0 +1,10 @@ +package com.correx.core.tasks + +import com.correx.core.events.events.StoredEvent + +interface TaskReducer { + fun reduce( + state: TaskState, + event: StoredEvent, + ): TaskState +} diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskService.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskService.kt new file mode 100644 index 00000000..04c9566e --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskService.kt @@ -0,0 +1,166 @@ +package com.correx.core.tasks + +import com.correx.core.events.events.EventMetadata +import com.correx.core.events.events.EventPayload +import com.correx.core.events.events.NewEvent +import com.correx.core.events.events.TaskAcceptanceCriteriaSetEvent +import com.correx.core.events.events.TaskAffectedPathsSetEvent +import com.correx.core.events.events.TaskBlockedEvent +import com.correx.core.events.events.TaskCancelledEvent +import com.correx.core.events.events.TaskClaimedEvent +import com.correx.core.events.events.TaskCompletedEvent +import com.correx.core.events.events.TaskCreatedEvent +import com.correx.core.events.events.TaskDeletedEvent +import com.correx.core.events.events.TaskEditedEvent +import com.correx.core.events.events.TaskLinkedEvent +import com.correx.core.events.events.TaskNoteAddedEvent +import com.correx.core.events.events.TaskReleasedEvent +import com.correx.core.events.events.TaskReopenedEvent +import com.correx.core.events.events.TaskSubmittedForReviewEvent +import com.correx.core.events.events.TaskUnblockedEvent +import com.correx.core.events.events.TaskUnlinkedEvent +import com.correx.core.events.stores.EventStore +import com.correx.core.events.types.EventId +import com.correx.core.events.types.ProjectId +import com.correx.core.events.types.TaskId +import com.correx.core.events.types.TaskLinkType +import com.correx.core.events.types.TaskNoteAuthor +import com.correx.core.events.types.TaskTargetKind +import com.correx.core.sessions.projections.replay.DefaultEventReplayer +import kotlinx.datetime.Clock +import java.util.UUID + +/** + * Write path for tasks: turns intents into events appended to the shared [EventStore]. + * The event log stays the single source of truth — the board is rebuilt by replay, never + * stored. All task events for a project live in one stream ([TaskStreams.forProject]). + * + * Keys are `-`, so a task's project is recoverable from its id; mutating + * methods therefore need only the [TaskId]. They return the rebuilt [Task], or null when the + * task does not exist (so callers — e.g. tools — can report a clean failure). Mutations that + * are illegal for the current status are still appended but are no-ops on replay (the reducer + * counts them in `invalidTransitions`). + */ +@Suppress("TooManyFunctions") +class TaskService( + private val eventStore: EventStore, + private val clock: Clock = Clock.System, +) { + private val boardReplayer = + DefaultEventReplayer(eventStore, TaskBoardProjector(DefaultTaskReducer())) + + fun board(projectId: ProjectId): TaskBoard = + boardReplayer.rebuild(TaskStreams.forProject(projectId)) + + fun list(projectId: ProjectId): List = + board(projectId).filterValues { !it.deleted }.map { (id, state) -> Task(id, state) } + + fun getTask(taskId: TaskId): Task? { + val state = board(projectOf(taskId))[taskId] ?: return null + return if (state.deleted) null else Task(taskId, state) + } + + suspend fun createTask( + projectId: ProjectId, + title: String, + goal: String, + acceptanceCriteria: List = emptyList(), + affectedPaths: List = emptyList(), + ): Task { + val key = nextKey(projectId) + val taskId = TaskId(key) + append( + projectId, + TaskCreatedEvent( + taskId = taskId, + projectId = projectId, + key = key, + title = title, + goal = goal, + acceptanceCriteria = acceptanceCriteria, + affectedPaths = affectedPaths, + ), + ) + return requireNotNull(getTask(taskId)) { "task $key missing after creation" } + } + + suspend fun edit(taskId: TaskId, title: String? = null, goal: String? = null): Task? = + mutate(taskId) { TaskEditedEvent(it, title = title, goal = goal) } + + suspend fun setAcceptanceCriteria(taskId: TaskId, criteria: List): Task? = + mutate(taskId) { TaskAcceptanceCriteriaSetEvent(it, criteria) } + + suspend fun setAffectedPaths(taskId: TaskId, paths: List): Task? = + mutate(taskId) { TaskAffectedPathsSetEvent(it, paths) } + + suspend fun claim(taskId: TaskId, claimant: String): Task? = + mutate(taskId) { TaskClaimedEvent(it, claimant) } + + suspend fun release(taskId: TaskId): Task? = + mutate(taskId) { TaskReleasedEvent(it) } + + suspend fun block(taskId: TaskId, reason: String): Task? = + mutate(taskId) { TaskBlockedEvent(it, reason) } + + suspend fun unblock(taskId: TaskId): Task? = + mutate(taskId) { TaskUnblockedEvent(it) } + + suspend fun submitForReview(taskId: TaskId): Task? = + mutate(taskId) { TaskSubmittedForReviewEvent(it) } + + suspend fun complete(taskId: TaskId): Task? = + mutate(taskId) { TaskCompletedEvent(it) } + + suspend fun reopen(taskId: TaskId): Task? = + mutate(taskId) { TaskReopenedEvent(it) } + + suspend fun cancel(taskId: TaskId, reason: String? = null): Task? = + mutate(taskId) { TaskCancelledEvent(it, reason) } + + suspend fun link(taskId: TaskId, targetId: String, type: TaskLinkType, targetKind: TaskTargetKind): Task? = + mutate(taskId) { TaskLinkedEvent(it, targetId, type, targetKind) } + + suspend fun unlink(taskId: TaskId, targetId: String, type: TaskLinkType): Task? = + mutate(taskId) { TaskUnlinkedEvent(it, targetId, type) } + + suspend fun addNote(taskId: TaskId, author: TaskNoteAuthor, body: String): Task? = + mutate(taskId) { TaskNoteAddedEvent(it, author, body) } + + /** Soft delete: appends a tombstone. After this, [getTask] returns null and [list] omits it. */ + suspend fun delete(taskId: TaskId): Boolean { + if (getTask(taskId) == null) return false + append(projectOf(taskId), TaskDeletedEvent(taskId)) + return true + } + + private suspend fun mutate(taskId: TaskId, event: (TaskId) -> EventPayload): Task? { + if (getTask(taskId) == null) return null + append(projectOf(taskId), event(taskId)) + return getTask(taskId) + } + + private fun nextKey(projectId: ProjectId): String { + val count = DefaultEventReplayer(eventStore, TaskCounterProjection(projectId.value)) + .rebuild(TaskStreams.forProject(projectId)).count + return "${projectId.value}-${count + 1}" + } + + private fun projectOf(taskId: TaskId): ProjectId = + ProjectId(taskId.value.substringBeforeLast('-')) + + private suspend fun append(projectId: ProjectId, payload: EventPayload) { + eventStore.append( + NewEvent( + metadata = EventMetadata( + eventId = EventId(UUID.randomUUID().toString()), + sessionId = TaskStreams.forProject(projectId), + timestamp = clock.now(), + schemaVersion = 1, + causationId = null, + correlationId = null, + ), + payload = payload, + ), + ) + } +} diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskState.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskState.kt new file mode 100644 index 00000000..32ddd882 --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskState.kt @@ -0,0 +1,26 @@ +package com.correx.core.tasks + +import com.correx.core.events.types.ProjectId +import kotlinx.datetime.Instant + +/** + * Projected state of a single task, rebuilt by folding its events through [TaskReducer]. + * Mirrors the shape of `SessionState`: defaults make the empty/pre-creation state valid. + */ +data class TaskState( + val status: TaskStatus = TaskStatus.TODO, + val key: String? = null, + val projectId: ProjectId? = null, + val title: String? = null, + val goal: String? = null, + val acceptanceCriteria: List = emptyList(), + val affectedPaths: List = emptyList(), + val links: List = emptyList(), + val claimant: String? = null, + val notes: List = emptyList(), + val createdAt: Instant? = null, + val updatedAt: Instant? = null, + val invalidTransitions: Int = 0, + /** Soft-deleted tombstone — repository reads hide these; the events remain in the log. */ + val deleted: Boolean = false, +) diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskStatus.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskStatus.kt new file mode 100644 index 00000000..3abc2be9 --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskStatus.kt @@ -0,0 +1,16 @@ +package com.correx.core.tasks + +/** + * Derived task lifecycle status. Never serialized on events — the reducer computes it from + * the lifecycle facts. Workflow: TODO → IN_PROGRESS → IN_REVIEW → DONE, with BLOCKED + * (reversible) and CANCELLED (terminal). + */ +enum class TaskStatus { + TODO, + IN_PROGRESS, + IN_REVIEW, + BLOCKED, + DONE, + CANCELLED, + ; +} diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskStreams.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskStreams.kt new file mode 100644 index 00000000..2fcee168 --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskStreams.kt @@ -0,0 +1,16 @@ +package com.correx.core.tasks + +import com.correx.core.events.types.ProjectId +import com.correx.core.events.types.SessionId + +/** + * Task events live in one event stream per project, keyed `tasks:`. The + * `tasks:` prefix keeps these streams distinguishable from real agent sessions (the + * EventStore is partitioned by [SessionId], and [SessionId]/`TaskId` are both `TypeId`). + */ +object TaskStreams { + const val PREFIX: String = "tasks:" + + fun forProject(projectId: ProjectId): SessionId = + SessionId("$PREFIX${projectId.value}") +} diff --git a/core/tasks/src/test/kotlin/com/correx/core/tasks/DefaultTaskReducerTest.kt b/core/tasks/src/test/kotlin/com/correx/core/tasks/DefaultTaskReducerTest.kt new file mode 100644 index 00000000..fae08179 --- /dev/null +++ b/core/tasks/src/test/kotlin/com/correx/core/tasks/DefaultTaskReducerTest.kt @@ -0,0 +1,147 @@ +package com.correx.core.tasks + +import com.correx.core.events.events.EventMetadata +import com.correx.core.events.events.EventPayload +import com.correx.core.events.events.StoredEvent +import com.correx.core.events.events.TaskAcceptanceCriteriaSetEvent +import com.correx.core.events.events.TaskBlockedEvent +import com.correx.core.events.events.TaskClaimedEvent +import com.correx.core.events.events.TaskCompletedEvent +import com.correx.core.events.events.TaskCreatedEvent +import com.correx.core.events.events.TaskLinkedEvent +import com.correx.core.events.events.TaskNoteAddedEvent +import com.correx.core.events.events.RefinementIterationEvent +import com.correx.core.events.events.TaskSubmittedForReviewEvent +import com.correx.core.events.events.TaskUnblockedEvent +import com.correx.core.events.types.EventId +import com.correx.core.events.types.ProjectId +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.TaskId +import com.correx.core.events.types.TaskLinkType +import com.correx.core.events.types.TaskNoteAuthor +import com.correx.core.events.types.TaskTargetKind +import kotlinx.datetime.Instant +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +class DefaultTaskReducerTest { + + private val reducer = DefaultTaskReducer() + private val taskId = TaskId("auth-1") + private val projectId = ProjectId("auth") + + private fun stored(payload: EventPayload, seq: Long = 1L) = StoredEvent( + metadata = EventMetadata( + eventId = EventId("e-$seq"), + sessionId = SessionId("tasks:auth"), + timestamp = Instant.parse("2026-01-0${seq}T00:00:00Z"), + schemaVersion = 1, + causationId = null, + correlationId = null, + ), + sequence = seq, + sessionSequence = seq, + payload = payload, + ) + + private fun reduceAll(vararg payloads: EventPayload): TaskState = + payloads.foldIndexed(TaskState()) { i, state, payload -> + reducer.reduce(state, stored(payload, (i + 1).toLong())) + } + + private fun created() = TaskCreatedEvent( + taskId = taskId, + projectId = projectId, + key = "auth-1", + title = "Implement JWT refresh flow", + goal = "users stay authenticated", + ) + + @Test + fun `TaskCreatedEvent sets fields and TODO status`() { + val state = reduceAll(created()) + + assertEquals(TaskStatus.TODO, state.status) + assertEquals("auth-1", state.key) + assertEquals(projectId, state.projectId) + assertEquals("Implement JWT refresh flow", state.title) + assertEquals("users stay authenticated", state.goal) + assertEquals(Instant.parse("2026-01-01T00:00:00Z"), state.createdAt) + assertEquals(0, state.invalidTransitions) + } + + @Test + fun `claiming moves TODO to IN_PROGRESS and records claimant`() { + val state = reduceAll(created(), TaskClaimedEvent(taskId, claimant = "claude-opus")) + + assertEquals(TaskStatus.IN_PROGRESS, state.status) + assertEquals("claude-opus", state.claimant) + } + + @Test + fun `happy path created to claimed to review to done`() { + val state = reduceAll( + created(), + TaskClaimedEvent(taskId, claimant = "claude-opus"), + TaskSubmittedForReviewEvent(taskId), + TaskCompletedEvent(taskId), + ) + + assertEquals(TaskStatus.DONE, state.status) + assertEquals(0, state.invalidTransitions) + } + + @Test + fun `unblock restores the working status of a claimed task`() { + val state = reduceAll( + created(), + TaskClaimedEvent(taskId, claimant = "claude-opus"), + TaskBlockedEvent(taskId, reason = "waiting on auth-138"), + TaskUnblockedEvent(taskId), + ) + + assertEquals(TaskStatus.IN_PROGRESS, state.status) + assertEquals(0, state.invalidTransitions) + } + + @Test + fun `illegal transition is ignored and counted`() { + val state = reduceAll(created(), TaskCompletedEvent(taskId)) + + assertEquals(TaskStatus.TODO, state.status) + assertEquals(1, state.invalidTransitions) + } + + @Test + fun `links and notes accumulate without affecting status`() { + val state = reduceAll( + created(), + TaskAcceptanceCriteriaSetEvent(taskId, listOf("refresh endpoint exists")), + TaskLinkedEvent(taskId, targetId = "auth-138", type = TaskLinkType.DEPENDS_ON, targetKind = TaskTargetKind.TASK), + TaskNoteAddedEvent(taskId, author = TaskNoteAuthor.AGENT, body = "migration failed"), + ) + + assertEquals(TaskStatus.TODO, state.status) + assertEquals(listOf("refresh endpoint exists"), state.acceptanceCriteria) + assertEquals(1, state.links.size) + assertEquals(TaskLink("auth-138", TaskLinkType.DEPENDS_ON, TaskTargetKind.TASK), state.links.single()) + assertEquals(1, state.notes.size) + assertEquals(TaskNoteAuthor.AGENT, state.notes.single().author) + } + + @Test + fun `non-task payload leaves state untouched`() { + val before = reduceAll(created()) + val nonTask = RefinementIterationEvent( + sessionId = SessionId("s-1"), + cycleKey = "implement->review", + iteration = 1, + maxIterations = 3, + ) + val after = reducer.reduce(before, stored(nonTask, seq = 9L)) + + assertEquals(before, after) + assertNull(after.claimant) + } +} diff --git a/core/tasks/src/test/kotlin/com/correx/core/tasks/InMemoryEventStore.kt b/core/tasks/src/test/kotlin/com/correx/core/tasks/InMemoryEventStore.kt new file mode 100644 index 00000000..02daa0d9 --- /dev/null +++ b/core/tasks/src/test/kotlin/com/correx/core/tasks/InMemoryEventStore.kt @@ -0,0 +1,50 @@ +package com.correx.core.tasks + +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.SessionId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow + +/** Minimal append-capable in-memory event store for core:tasks tests, keyed per session. */ +internal class InMemoryEventStore : EventStore { + private val events = mutableListOf() + private var global = 0L + private val perSession = mutableMapOf() + + override suspend fun append(event: NewEvent): StoredEvent { + global += 1 + val seq = (perSession[event.metadata.sessionId] ?: 0L) + 1 + perSession[event.metadata.sessionId] = seq + val stored = StoredEvent( + metadata = event.metadata, + sequence = global, + sessionSequence = seq, + payload = event.payload, + ) + events += stored + return stored + } + + override suspend fun appendAll(events: List): List = events.map { append(it) } + + override fun read(sessionId: SessionId): List = + events.filter { it.metadata.sessionId == sessionId }.sortedBy { it.sessionSequence } + + override fun readFrom(sessionId: SessionId, fromSequence: Long): List = + read(sessionId).filter { it.sessionSequence >= fromSequence } + + override fun lastSequence(sessionId: SessionId): Long? = + read(sessionId).maxOfOrNull { it.sessionSequence } + + override fun subscribe(sessionId: SessionId): Flow = emptyFlow() + + override fun subscribeAll(): Flow = emptyFlow() + + override suspend fun lastGlobalSequence(): Long = global + + override fun allEvents(): Sequence = events.asSequence() + + override fun allSessionIds(): Set = events.map { it.metadata.sessionId }.toSet() +} diff --git a/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskBoardProjectorTest.kt b/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskBoardProjectorTest.kt new file mode 100644 index 00000000..4450982a --- /dev/null +++ b/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskBoardProjectorTest.kt @@ -0,0 +1,74 @@ +package com.correx.core.tasks + +import com.correx.core.events.events.EventMetadata +import com.correx.core.events.events.EventPayload +import com.correx.core.events.events.RefinementIterationEvent +import com.correx.core.events.events.StoredEvent +import com.correx.core.events.events.TaskClaimedEvent +import com.correx.core.events.events.TaskCreatedEvent +import com.correx.core.events.types.EventId +import com.correx.core.events.types.ProjectId +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.TaskId +import kotlinx.datetime.Instant +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class TaskBoardProjectorTest { + + private val projector = TaskBoardProjector(DefaultTaskReducer()) + private val projectId = ProjectId("auth") + + private fun stored(payload: EventPayload, seq: Long) = StoredEvent( + metadata = EventMetadata( + eventId = EventId("e-$seq"), + sessionId = SessionId("tasks:auth"), + timestamp = Instant.parse("2026-01-01T00:00:0${seq}Z"), + schemaVersion = 1, + causationId = null, + correlationId = null, + ), + sequence = seq, + sessionSequence = seq, + payload = payload, + ) + + private fun created(id: TaskId) = + TaskCreatedEvent(id, projectId, id.value, "title ${id.value}", "goal") + + private fun fold(vararg events: StoredEvent): TaskBoard = + events.fold(projector.initial()) { board, event -> projector.apply(board, event) } + + @Test + fun `interleaved events update each task independently`() { + val one = TaskId("auth-1") + val two = TaskId("auth-2") + + val board = fold( + stored(created(one), 1), + stored(created(two), 2), + stored(TaskClaimedEvent(one, claimant = "claude-opus"), 3), + ) + + assertEquals(2, board.size) + assertEquals(TaskStatus.IN_PROGRESS, board[one]?.status) + assertEquals("claude-opus", board[one]?.claimant) + assertEquals(TaskStatus.TODO, board[two]?.status) + } + + @Test + fun `non-task payload leaves the board unchanged`() { + val one = TaskId("auth-1") + val withTask = fold(stored(created(one), 1)) + + val nonTask = RefinementIterationEvent( + sessionId = SessionId("s-1"), + cycleKey = "implement->review", + iteration = 1, + maxIterations = 3, + ) + val after = projector.apply(withTask, stored(nonTask, 2)) + + assertEquals(withTask, after) + } +} diff --git a/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskContextAssemblerTest.kt b/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskContextAssemblerTest.kt new file mode 100644 index 00000000..5c3cf5d1 --- /dev/null +++ b/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskContextAssemblerTest.kt @@ -0,0 +1,128 @@ +package com.correx.core.tasks + +import com.correx.core.events.types.ProjectId +import com.correx.core.events.types.TaskId +import com.correx.core.events.types.TaskLinkType +import com.correx.core.events.types.TaskNoteAuthor +import com.correx.core.events.types.TaskTargetKind +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class TaskContextAssemblerTest { + + private val store = InMemoryEventStore() + private val service = TaskService(store) + private val assembler = TaskContextAssembler(service) + private val project = ProjectId("auth") + + @Test + fun `bundle resolves linked tasks as dependencies and keeps non-task links raw`() = runBlocking { + val dep = service.createTask(project, "Design auth model", "model") + service.claim(dep.taskId, "claude-opus") + service.submitForReview(dep.taskId) + service.complete(dep.taskId) // dep is DONE + + val task = service.createTask( + project, + title = "Implement JWT refresh", + goal = "users stay authenticated", + acceptanceCriteria = listOf("refresh endpoint exists"), + affectedPaths = listOf("backend/auth/**"), + ) + service.link(task.taskId, dep.taskId.value, TaskLinkType.DEPENDS_ON, TaskTargetKind.TASK) + // DOC-tagged, but this assembler has no document resolver → stays a raw related link + service.link(task.taskId, "adr-7", TaskLinkType.IMPLEMENTS, TaskTargetKind.DOC) + service.addNote(task.taskId, TaskNoteAuthor.AGENT, "kickoff") + + val bundle = assembler.assemble(task.taskId)!! + + assertEquals("auth-2", bundle.id) + assertEquals("users stay authenticated", bundle.goal) + assertEquals(listOf("refresh endpoint exists"), bundle.acceptanceCriteria) + assertEquals(listOf("backend/auth/**"), bundle.relevantFiles) + + val dependency = bundle.dependencies.single() + assertEquals("auth-1", dependency.id) + assertEquals("DONE", dependency.status) + assertEquals("DEPENDS_ON", dependency.link) + + assertEquals(RelatedLink("adr-7", "IMPLEMENTS"), bundle.relatedLinks.single()) + assertEquals("kickoff", bundle.notes.single().body) + } + + @Test + fun `render produces a compact labelled bundle`() = runBlocking { + val task = service.createTask(project, "JWT refresh", "stay authed", listOf("rotates")) + val text = assembler.assemble(task.taskId)!!.render() + + assertTrue(text.startsWith("task auth-1 [TODO] JWT refresh")) + assertTrue(text.contains("goal: stay authed")) + assertTrue(text.contains("- rotates")) + } + + @Test + fun `assemble returns null for an unknown task`() = runBlocking { + assertNull(assembler.assemble(TaskId("auth-999"))) + } + + @Test + fun `bundle is enriched with knowledge retrieved over the goal`() = runBlocking { + val captured = mutableListOf() + val fakeRetriever = object : TaskKnowledgeRetriever { + override suspend fun retrieve(query: String, limit: Int): List { + captured += query + return listOf(KnowledgeHit("backend/auth/Jwt.kt", "fun refresh(token)", 0.91)) + } + } + val enriched = TaskContextAssembler(service, fakeRetriever) + val task = service.createTask(project, "JWT refresh", "users stay authenticated") + + val bundle = enriched.assemble(task.taskId)!! + + assertEquals(listOf("JWT refresh users stay authenticated"), captured) + assertEquals("backend/auth/Jwt.kt", bundle.relevantKnowledge.single().source) + assertTrue(bundle.render().contains("relevant_knowledge")) + } + + @Test + fun `linked docs are resolved inline and dropped from raw related links`() = runBlocking { + val docs = object : TaskDocumentResolver { + override suspend fun resolve(targetId: String): ResolvedDocument? = + if (targetId == "adr-7") { + ResolvedDocument("ADR 7: use redis", "docs/decisions/adr-0007-redis.md", "faster invalidation") + } else { + null + } + } + val enriched = TaskContextAssembler(service, documentResolver = docs) + val task = service.createTask(project, "JWT refresh", "auth") + service.link(task.taskId, "adr-7", TaskLinkType.IMPLEMENTS, TaskTargetKind.DOC) // resolves to a doc + service.link(task.taskId, "ext-123", TaskLinkType.RELATES_TO, TaskTargetKind.ARTIFACT) // stays raw + + val bundle = enriched.assemble(task.taskId)!! + + val doc = bundle.documents.single() + assertEquals("adr-7", doc.targetId) + assertEquals("ADR 7: use redis", doc.title) + assertEquals("IMPLEMENTS", doc.type) + assertEquals(RelatedLink("ext-123", "RELATES_TO"), bundle.relatedLinks.single()) + assertTrue(bundle.render().contains("documents:")) + } + + @Test + fun `retrieval failure is swallowed and the bundle still returns`() = runBlocking { + val flaky = object : TaskKnowledgeRetriever { + override suspend fun retrieve(query: String, limit: Int): List = + error("embedder offline") + } + val task = service.createTask(project, "JWT refresh", "stay authed") + + val bundle = TaskContextAssembler(service, flaky).assemble(task.taskId)!! + + assertTrue(bundle.relevantKnowledge.isEmpty()) + assertEquals("JWT refresh", bundle.title) + } +} diff --git a/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskCounterProjectionTest.kt b/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskCounterProjectionTest.kt new file mode 100644 index 00000000..dc707cc0 --- /dev/null +++ b/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskCounterProjectionTest.kt @@ -0,0 +1,51 @@ +package com.correx.core.tasks + +import com.correx.core.events.events.EventMetadata +import com.correx.core.events.events.EventPayload +import com.correx.core.events.events.StoredEvent +import com.correx.core.events.events.TaskCancelledEvent +import com.correx.core.events.events.TaskCreatedEvent +import com.correx.core.events.types.EventId +import com.correx.core.events.types.ProjectId +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.TaskId +import kotlinx.datetime.Instant +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class TaskCounterProjectionTest { + + private val projection = TaskCounterProjection("auth") + private val projectId = ProjectId("auth") + + private fun stored(payload: EventPayload, seq: Long) = StoredEvent( + metadata = EventMetadata( + eventId = EventId("e-$seq"), + sessionId = SessionId("tasks:auth"), + timestamp = Instant.parse("2026-01-01T00:00:0${seq}Z"), + schemaVersion = 1, + causationId = null, + correlationId = null, + ), + sequence = seq, + sessionSequence = seq, + payload = payload, + ) + + private fun created(id: TaskId) = + TaskCreatedEvent(id, projectId, id.value, "title", "goal") + + @Test + fun `counts only created events and survives cancellation`() { + val events = listOf( + stored(created(TaskId("auth-1")), 1), + stored(created(TaskId("auth-2")), 2), + stored(TaskCancelledEvent(TaskId("auth-1")), 3), + ) + + val state = events.fold(projection.initial()) { s, e -> projection.apply(s, e) } + + assertEquals("auth", state.projectId) + assertEquals(2, state.count) + } +} diff --git a/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskServiceTest.kt b/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskServiceTest.kt new file mode 100644 index 00000000..83bc0213 --- /dev/null +++ b/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskServiceTest.kt @@ -0,0 +1,84 @@ +package com.correx.core.tasks + +import com.correx.core.events.types.ProjectId +import com.correx.core.events.types.TaskId +import com.correx.core.events.types.TaskLinkType +import com.correx.core.events.types.TaskNoteAuthor +import com.correx.core.events.types.TaskTargetKind +import kotlinx.coroutines.runBlocking +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 TaskServiceTest { + + private val store = InMemoryEventStore() + private val service = TaskService(store) + private val project = ProjectId("auth") + + @Test + fun `createTask allocates sequential human keys per project`() = runBlocking { + val first = service.createTask(project, "Add JWT refresh", "users stay authenticated") + val second = service.createTask(project, "Rotate keys", "tokens rotate") + + assertEquals("auth-1", first.taskId.value) + assertEquals("auth-2", second.taskId.value) + assertEquals(TaskStatus.TODO, first.state.status) + assertEquals("Add JWT refresh", first.state.title) + } + + @Test + fun `lifecycle round-trips through append and replay`() = runBlocking { + val task = service.createTask(project, "Add JWT refresh", "users stay authenticated") + val id = task.taskId + + assertEquals(TaskStatus.IN_PROGRESS, service.claim(id, "claude-opus")?.state?.status) + assertEquals(TaskStatus.IN_REVIEW, service.submitForReview(id)?.state?.status) + val done = service.complete(id) + + assertEquals(TaskStatus.DONE, done?.state?.status) + assertEquals("claude-opus", done?.state?.claimant) + } + + @Test + fun `links and notes are persisted on the task`() = runBlocking { + val id = service.createTask(project, "Add JWT refresh", "auth").taskId + + service.link(id, targetId = "adr-7", type = TaskLinkType.IMPLEMENTS, targetKind = TaskTargetKind.DOC) + val withNote = service.addNote(id, TaskNoteAuthor.AGENT, "migration failed, retrying") + + assertEquals( + TaskLink("adr-7", TaskLinkType.IMPLEMENTS, TaskTargetKind.DOC), + withNote?.state?.links?.single(), + ) + assertEquals("migration failed, retrying", withNote?.state?.notes?.single()?.body) + } + + @Test + fun `delete is a soft tombstone hidden from reads`() = runBlocking { + val id = service.createTask(project, "throwaway", "oops").taskId + + assertTrue(service.delete(id)) + assertNull(service.getTask(id)) + assertTrue(service.list(project).none { it.taskId == id }) + // A second delete is a no-op: the task is already gone from reads. + assertFalse(service.delete(id)) + } + + @Test + fun `mutating an unknown task returns null`() = runBlocking { + assertNull(service.claim(TaskId("auth-999"), "claude-opus")) + } + + @Test + fun `keys keep incrementing even after deletion`() = runBlocking { + val first = service.createTask(project, "one", "g").taskId + service.delete(first) + val third = service.createTask(project, "two", "g") + + // count() is creation-based, so the deleted id is never reused. + assertEquals("auth-2", third.taskId.value) + } +} diff --git a/docs/decisions/adr-0012-task-tracking.md b/docs/decisions/adr-0012-task-tracking.md new file mode 100644 index 00000000..36a75314 --- /dev/null +++ b/docs/decisions/adr-0012-task-tracking.md @@ -0,0 +1,112 @@ +--- +name: "Adr 0012 Task Tracking" +description: "Native task tracking as an event-sourced aggregate; tasks are a first-class work-graph node" +depth: 2 +links: ["../index.md", "./adr-0001-event-sourcing.md", "./adr-0005-persistence-choice.md", "./adr-0006-event-store-append-only.md"] +--- + +# ADR 0012: native task tracking as an event-sourced aggregate + +**status:** accepted +**date:** 23.06.2026 (June) +**deciders:** correx design team + +--- + +## context + +Correx is to gain **native task tracking** — a durable "task" (work item / issue) entity, +agent-first. Today the system has no concept of a task; it has `sessions` (agent runs) and +session/stage-scoped `artifacts`. A task is conceptually different: a durable unit of intent +that outlives any single session and *references* the artifacts and sessions that act on it. + +An originating pitch proposed storing tasks as YAML/markdown files as the **source of truth** +with Postgres as a cache, plus a `GET /tasks/next` assignment scheduler. Those technical +choices are **rejected** here: they contradict correx's foundational invariants — +"the event log is the only source of truth; projections are disposable and rebuilt from +events" (`.correx/project.toml`, ADR-0001, ADR-0006). Adopting hand-edited files as truth +would create two masters (files vs. log) and forfeit replay/idempotency. We keep the *goal* +(native tasks) and realise it with correx's actual architecture. + +This ADR covers the **foundational slice**: the aggregate, its events, and the work-graph +edge model. REST/CLI/agent-context-bundle/search are deliberately later phases. + +## decision + +**A task is a first-class event-sourced aggregate**, modelled on the existing `core:sessions` +aggregate (Status / State / Reducer / Projector / Repository / Counter), living in a new +`core:tasks` module. It is *not* an `ArtifactKind` — artifacts are session/stage-scoped LLM +outputs; a task is durable and cross-session. + +### 1. event log is the source of truth +Tasks are rebuilt by folding events. Any future markdown/file representation is a *disposable +projection* (export), never the master copy. + +### 2. per-project task stream +The `EventStore` is partitioned by `sessionId`. Since `SessionId` and `TaskId` are both +`TypeId`, all task events for a project live in one stream keyed `tasks:` +(`TaskStreams.forProject`). One read rebuilds the whole board (`TaskBoard = Map`). The `tasks:` prefix keeps these streams distinguishable from real agent +sessions; when task streams are later wired into the live server, session-listing consumers +filter the prefix. + +### 3. lifecycle as facts; status is derived +Mirroring sessions (whose events never carry `SessionStatus`), task events are facts +(`TaskClaimed`, `TaskBlocked`, `TaskCompleted`, …) and `DefaultTaskReducer` derives +`TaskStatus`. This keeps the `TaskStatus` enum out of the lowest module (`core:events`). A +`sealed interface TaskEvent { val taskId }` (a marker, *not* part of the serialized +`EventPayload` hierarchy) lets projections route any payload to its task without a giant `when`. + +### 4. status workflow +`TODO → IN_PROGRESS → IN_REVIEW → DONE`, with `BLOCKED` (reversible; unblock returns to +IN_PROGRESS if claimed, else TODO) and `CANCELLED` (terminal, reopenable). Illegal +transitions are ignored and counted in `TaskState.invalidTransitions` (as `SessionState` does). + +### 5. work-graph edges +`TaskLinkType` (sibling to the existing `ArtifactRelationshipType`): +`DEPENDS_ON, BLOCKS, IMPLEMENTS, RELATES_TO, PRODUCED, CONTEXT`. A link target is a plain id +string — another task, an `ArtifactId`, or a `SessionId` — plus a type. This is the work-graph +backbone the agent-context bundle (later phase) will traverse. + +### 6. human-friendly ids +`TaskCounterProjection(projectId)` counts `TaskCreatedEvent`s in the project stream; the +numeric suffix of a key (`-`, e.g. `auth-142`) is the count of *created* tasks, so +ids never collide even after cancellation. The creating caller composes the key and stores it +in `TaskCreatedEvent.key`. (The creating service is a later phase; the projection and `key` +field land now.) + +## consequences + +**positive:** + +* reuses the entire event-sourcing backbone (sequencing, idempotency, replay, projections); + no new persistence model, no second source of truth +* the work graph (tasks ↔ tasks/artifacts/sessions) is native, enabling later + "everything touching X" queries and the agent-context bundle +* deterministic, replayable task state; status derivation is unit-testable in isolation + +**negative:** + +* overloading the `sessionId`-partitioned stream key with `tasks:` means + session-enumerating consumers must learn to filter the prefix once tasks go live + (acceptable; isolated to the live-wiring phase) +* per-project (not per-task) stream means rebuilding one task replays the project's task + events — fine at expected volumes; revisit with snapshots if a project's task log grows large + +## alternatives considered + +* **Task as an `ArtifactKind`:** rejected — artifacts are session/stage-scoped outputs; + tasks are durable, cross-session, and carry their own lifecycle/claiming. +* **YAML/markdown files as source of truth + Postgres cache (the pitch):** rejected — + violates ADR-0001/0006; creates two masters and loses replay/idempotency. +* **`GET /tasks/next` assignment scheduler (the pitch):** dropped — scheduling/prioritisation + is a separate hard problem; v1 offers `claim` (pull), not `assign` (push). +* **One stream per task:** cleaner per-task sequencing but pollutes session enumeration with + one stream per task and makes board/list queries fan out across many streams; the + per-project stream is the better default for a tracker that lists/boards constantly. + +## status + +Governs the foundational task-tracking slice. Re-evaluation expected when the REST surface, +the `GET /tasks/{id}/context` agent bundle, search, and git-driven auto status updates are +designed (those phases build on, and must not contradict, this ADR). diff --git a/infrastructure/src/main/kotlin/com/correx/infrastructure/InfrastructureModule.kt b/infrastructure/src/main/kotlin/com/correx/infrastructure/InfrastructureModule.kt index 475965d6..8b65bc9e 100644 --- a/infrastructure/src/main/kotlin/com/correx/infrastructure/InfrastructureModule.kt +++ b/infrastructure/src/main/kotlin/com/correx/infrastructure/InfrastructureModule.kt @@ -35,6 +35,7 @@ import com.correx.core.router.model.RouterConfig import com.correx.core.router.model.WorkflowSummary import com.correx.core.events.types.SessionId import com.correx.core.sessions.projections.replay.DefaultEventReplayer +import com.correx.core.tools.contract.Tool import com.correx.core.tools.contract.ToolExecutor import com.correx.core.tools.registry.ToolRegistry import com.correx.infrastructure.artifactscas.CasArtifactStore @@ -104,8 +105,11 @@ object InfrastructureModule { return runBlocking { CasArtifactStore.open(CasConfig(rootDir), index) } } - fun createToolRegistry(config: ToolConfig): ToolRegistry = - DefaultToolRegistry.build(config.buildTools()) + fun createToolRegistry( + config: ToolConfig, + extraTools: List = emptyList(), + ): ToolRegistry = + DefaultToolRegistry.build(config.buildTools() + extraTools) fun createLlamaCppProvider( modelId: String, diff --git a/infrastructure/tools/build.gradle b/infrastructure/tools/build.gradle index 923bf246..c9dc23c3 100644 --- a/infrastructure/tools/build.gradle +++ b/infrastructure/tools/build.gradle @@ -13,6 +13,7 @@ dependencies { implementation project(":core:events") implementation project(":core:approvals") implementation project(":core:sessions") + implementation project(":core:tasks") implementation project(":infrastructure:tools:filesystem") implementation project(":core:artifacts") implementation project(":core:artifacts-store") diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskContextTool.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskContextTool.kt new file mode 100644 index 00000000..38953f8d --- /dev/null +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskContextTool.kt @@ -0,0 +1,56 @@ +package com.correx.infrastructure.tools.task + +import com.correx.core.approvals.Tier +import com.correx.core.events.events.ToolRequest +import com.correx.core.events.types.TaskId +import com.correx.core.tasks.TaskContextAssembler +import com.correx.core.tools.contract.Tool +import com.correx.core.tools.contract.ToolCapability +import com.correx.core.tools.contract.ToolExecutor +import com.correx.core.tools.contract.ToolResult +import com.correx.core.tools.contract.ValidationResult +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.add +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonObject + +/** + * Agent-facing tool: fetch a task's token-optimized context bundle in one call. Read-only, so + * it sits at the lowest tier (like file_read) — agents call it before starting work on a task. + */ +class TaskContextTool(private val assembler: TaskContextAssembler) : Tool, ToolExecutor { + + override val name: String = "task_context" + override val description: String = + "Fetch a task's context bundle (goal, acceptance criteria, resolved dependencies, " + + "related links, relevant files, notes) in one call. Use before working a task." + override val parametersSchema: JsonObject = buildJsonObject { + put("type", "object") + putJsonObject("properties") { + putJsonObject("id") { put("type", "string"); put("description", "Task id, e.g. 'auth-142'.") } + } + put("required", buildJsonArray { add(JsonPrimitive("id")) }) + } + override val tier: Tier = Tier.T1 + override val requiredCapabilities: Set = emptySet() + + override fun validateRequest(request: ToolRequest): ValidationResult { + request.stringParam("id") ?: return ValidationResult.Invalid("Missing 'id' (string).") + return ValidationResult.Valid + } + + override suspend fun execute(request: ToolRequest): ToolResult { + val id = request.stringParam("id") + ?: return ToolResult.Failure(request.invocationId, "Missing 'id' (string).", recoverable = false) + val bundle = assembler.assemble(TaskId(id)) + ?: return ToolResult.Failure(request.invocationId, "No such task: $id", recoverable = false) + return ToolResult.Success( + invocationId = request.invocationId, + output = bundle.render(), + metadata = mapOf("taskId" to id, "status" to bundle.status), + ) + } +} diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskCreateTool.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskCreateTool.kt new file mode 100644 index 00000000..c98e8d7c --- /dev/null +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskCreateTool.kt @@ -0,0 +1,89 @@ +package com.correx.infrastructure.tools.task + +import com.correx.core.approvals.Tier +import com.correx.core.events.events.ToolRequest +import com.correx.core.events.types.ProjectId +import com.correx.core.tasks.TaskService +import com.correx.core.tools.contract.Tool +import com.correx.core.tools.contract.ToolCapability +import com.correx.core.tools.contract.ToolExecutor +import com.correx.core.tools.contract.ToolResult +import com.correx.core.tools.contract.ValidationResult +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.add +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonObject + +/** Agent-facing tool: create a task in a project's work graph. */ +class TaskCreateTool(private val service: TaskService) : Tool, ToolExecutor { + + override val name: String = "task_create" + override val description: String = + "Create a task in a project's work graph and return its id (e.g. 'auth-142')." + override val parametersSchema: JsonObject = buildJsonObject { + put("type", "object") + putJsonObject("properties") { + putJsonObject("project") { + put("type", "string") + put("description", "Project key, e.g. 'auth'. The new task id is '-'.") + } + putJsonObject("title") { + put("type", "string") + put("description", "Short imperative title.") + } + putJsonObject("goal") { + put("type", "string") + put("description", "What 'done' looks like, in a sentence or two.") + } + putJsonObject("acceptance_criteria") { + put("type", "array") + putJsonObject("items") { put("type", "string") } + put("description", "Concrete, verifiable criteria.") + } + putJsonObject("affected_paths") { + put("type", "array") + putJsonObject("items") { put("type", "string") } + put("description", "Globs/paths this task is expected to touch.") + } + } + put( + "required", + buildJsonArray { + add(JsonPrimitive("project")) + add(JsonPrimitive("title")) + add(JsonPrimitive("goal")) + }, + ) + } + override val tier: Tier = Tier.T2 + override val requiredCapabilities: Set = emptySet() + + override fun validateRequest(request: ToolRequest): ValidationResult { + request.stringParam("project") ?: return ValidationResult.Invalid("Missing 'project' (string).") + request.stringParam("title") ?: return ValidationResult.Invalid("Missing 'title' (string).") + request.stringParam("goal") ?: return ValidationResult.Invalid("Missing 'goal' (string).") + return ValidationResult.Valid + } + + override suspend fun execute(request: ToolRequest): ToolResult { + val invalid = validateRequest(request) as? ValidationResult.Invalid + if (invalid != null) { + return ToolResult.Failure(request.invocationId, invalid.reason, recoverable = false) + } + val task = service.createTask( + projectId = ProjectId(request.stringParam("project")!!), + title = request.stringParam("title")!!, + goal = request.stringParam("goal")!!, + acceptanceCriteria = request.listParam("acceptance_criteria"), + affectedPaths = request.listParam("affected_paths"), + ) + return ToolResult.Success( + invocationId = request.invocationId, + output = "Created ${task.taskId.value}: ${task.state.title}", + metadata = mapOf("taskId" to task.taskId.value, "status" to task.state.status.name), + ) + } +} diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskDeleteTool.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskDeleteTool.kt new file mode 100644 index 00000000..0442fe8a --- /dev/null +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskDeleteTool.kt @@ -0,0 +1,55 @@ +package com.correx.infrastructure.tools.task + +import com.correx.core.approvals.Tier +import com.correx.core.events.events.ToolRequest +import com.correx.core.events.types.TaskId +import com.correx.core.tasks.TaskService +import com.correx.core.tools.contract.Tool +import com.correx.core.tools.contract.ToolCapability +import com.correx.core.tools.contract.ToolExecutor +import com.correx.core.tools.contract.ToolResult +import com.correx.core.tools.contract.ValidationResult +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.add +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonObject + +/** + * Agent-facing tool: soft-delete a task (tombstone). History is preserved in the event log; + * the task drops out of active views. Use 'task_update' with action=cancel to record an + * abandoned-but-visible outcome instead. + */ +class TaskDeleteTool(private val service: TaskService) : Tool, ToolExecutor { + + override val name: String = "task_delete" + override val description: String = + "Soft-delete a task (removes it from active views; history is kept). " + + "For an abandoned-but-visible task, use task_update action=cancel instead." + override val parametersSchema: JsonObject = buildJsonObject { + put("type", "object") + putJsonObject("properties") { + putJsonObject("id") { put("type", "string"); put("description", "Task id, e.g. 'auth-142'.") } + } + put("required", buildJsonArray { add(JsonPrimitive("id")) }) + } + override val tier: Tier = Tier.T2 + override val requiredCapabilities: Set = emptySet() + + override fun validateRequest(request: ToolRequest): ValidationResult { + request.stringParam("id") ?: return ValidationResult.Invalid("Missing 'id' (string).") + return ValidationResult.Valid + } + + override suspend fun execute(request: ToolRequest): ToolResult { + val id = request.stringParam("id") + ?: return ToolResult.Failure(request.invocationId, "Missing 'id' (string).", recoverable = false) + return if (service.delete(TaskId(id))) { + ToolResult.Success(request.invocationId, output = "Deleted $id", metadata = mapOf("taskId" to id)) + } else { + ToolResult.Failure(request.invocationId, "No such task: $id", recoverable = false) + } + } +} diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskTools.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskTools.kt new file mode 100644 index 00000000..fe25c87a --- /dev/null +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskTools.kt @@ -0,0 +1,26 @@ +package com.correx.infrastructure.tools.task + +import com.correx.core.tasks.TaskContextAssembler +import com.correx.core.tasks.TaskDocumentResolver +import com.correx.core.tasks.TaskKnowledgeRetriever +import com.correx.core.tasks.TaskService +import com.correx.core.tools.contract.Tool + +/** Builds the task tool set over a single [TaskService] for registration in the tool registry. */ +object TaskTools { + /** + * [retriever] semantically enriches the `task_context` bundle; [documentResolver] inlines + * linked ADRs/docs. Both optional — null leaves the bundle deterministic. + */ + fun forService( + service: TaskService, + retriever: TaskKnowledgeRetriever? = null, + documentResolver: TaskDocumentResolver? = null, + ): List = + listOf( + TaskCreateTool(service), + TaskUpdateTool(service), + TaskDeleteTool(service), + TaskContextTool(TaskContextAssembler(service, retriever, documentResolver)), + ) +} diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt new file mode 100644 index 00000000..15a8f447 --- /dev/null +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt @@ -0,0 +1,158 @@ +package com.correx.infrastructure.tools.task + +import com.correx.core.approvals.Tier +import com.correx.core.events.events.ToolRequest +import com.correx.core.events.types.TaskId +import com.correx.core.events.types.TaskLinkType +import com.correx.core.events.types.TaskNoteAuthor +import com.correx.core.events.types.TaskTargetKind +import com.correx.core.tasks.TaskService +import com.correx.core.tools.contract.Tool +import com.correx.core.tools.contract.ToolCapability +import com.correx.core.tools.contract.ToolExecutor +import com.correx.core.tools.contract.ToolResult +import com.correx.core.tools.contract.ValidationResult +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.add +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonObject + +private val ADR_ID = Regex("(?i)^adr[-_ ]?\\d+$") + +/** + * Agent-facing tool: update an existing task — edit fields, transition status, add a work-graph + * link, or attach a note. The task's project is derived from its id, so only the id is required. + */ +class TaskUpdateTool(private val service: TaskService) : Tool, ToolExecutor { + + override val name: String = "task_update" + override val description: String = + "Update a task: edit title/goal/criteria/paths, change status via 'action' " + + "(claim, release, block, unblock, submit_for_review, complete, reopen, cancel), " + + "add a link, or add a note." + override val parametersSchema: JsonObject = buildJsonObject { + put("type", "object") + putJsonObject("properties") { + putJsonObject("id") { put("type", "string"); put("description", "Task id, e.g. 'auth-142'.") } + putJsonObject("title") { put("type", "string"); put("description", "New title.") } + putJsonObject("goal") { put("type", "string"); put("description", "New goal.") } + putJsonObject("acceptance_criteria") { + put("type", "array") + putJsonObject("items") { put("type", "string") } + put("description", "Replaces the acceptance criteria.") + } + putJsonObject("affected_paths") { + put("type", "array") + putJsonObject("items") { put("type", "string") } + put("description", "Replaces the affected paths.") + } + putJsonObject("action") { + put("type", "string") + put( + "description", + "Status transition: claim, release, block, unblock, submit_for_review, " + + "complete, reopen, cancel.", + ) + } + putJsonObject("claimant") { put("type", "string"); put("description", "Who claims it (for action=claim).") } + putJsonObject("reason") { put("type", "string"); put("description", "Reason (for action=block/cancel).") } + putJsonObject("link_target") { put("type", "string"); put("description", "Id to link to (task/artifact/session).") } + putJsonObject("link_type") { + put("type", "string") + put("description", "DEPENDS_ON, BLOCKS, IMPLEMENTS, RELATES_TO, PRODUCED, CONTEXT. Default RELATES_TO.") + } + putJsonObject("link_kind") { + put("type", "string") + put("description", "What the link points at: TASK, DOC, ARTIFACT, SESSION. Omit to infer from the target id.") + } + putJsonObject("note") { put("type", "string"); put("description", "Append an agent note.") } + } + put("required", buildJsonArray { add(JsonPrimitive("id")) }) + } + override val tier: Tier = Tier.T2 + override val requiredCapabilities: Set = emptySet() + + override fun validateRequest(request: ToolRequest): ValidationResult { + request.stringParam("id") ?: return ValidationResult.Invalid("Missing 'id' (string).") + return ValidationResult.Valid + } + + override suspend fun execute(request: ToolRequest): ToolResult { + val id = request.stringParam("id") + ?: return fail(request, "Missing 'id' (string).") + val taskId = TaskId(id) + if (service.getTask(taskId) == null) return fail(request, "No such task: $id") + + applyFieldEdits(request, taskId) + + val action = request.stringParam("action") + if (action != null && !applyAction(taskId, action, request)) { + return fail(request, "Unknown action '$action'.") + } + + val linkTarget = request.stringParam("link_target") + if (linkTarget != null) { + val type = parseLinkType(request.stringParam("link_type")) + ?: return fail(request, "Invalid 'link_type'.") + val kind = resolveTargetKind(request.stringParam("link_kind"), linkTarget) + ?: return fail(request, "Invalid 'link_kind'.") + service.link(taskId, linkTarget, type, kind) + } + + request.stringParam("note")?.let { service.addNote(taskId, TaskNoteAuthor.AGENT, it) } + + val status = service.getTask(taskId)?.state?.status?.name ?: "UNKNOWN" + return ToolResult.Success( + invocationId = request.invocationId, + output = "Updated $id [$status]", + metadata = mapOf("taskId" to id, "status" to status), + ) + } + + private suspend fun applyFieldEdits(request: ToolRequest, taskId: TaskId) { + val title = request.stringParam("title") + val goal = request.stringParam("goal") + if (title != null || goal != null) service.edit(taskId, title, goal) + if (request.parameters.containsKey("acceptance_criteria")) { + service.setAcceptanceCriteria(taskId, request.listParam("acceptance_criteria")) + } + if (request.parameters.containsKey("affected_paths")) { + service.setAffectedPaths(taskId, request.listParam("affected_paths")) + } + } + + private suspend fun applyAction(taskId: TaskId, action: String, request: ToolRequest): Boolean { + val claimant = request.stringParam("claimant") ?: request.sessionId.value + val reason = request.stringParam("reason") + when (action) { + "claim" -> service.claim(taskId, claimant) + "release" -> service.release(taskId) + "block" -> service.block(taskId, reason ?: "blocked") + "unblock" -> service.unblock(taskId) + "submit_for_review" -> service.submitForReview(taskId) + "complete" -> service.complete(taskId) + "reopen" -> service.reopen(taskId) + "cancel" -> service.cancel(taskId, reason) + else -> return false + } + return true + } + + private fun parseLinkType(raw: String?): TaskLinkType? = + if (raw == null) TaskLinkType.RELATES_TO + else TaskLinkType.entries.firstOrNull { it.name == raw.uppercase() } + + /** Explicit kind when given (null if unrecognised); otherwise inferred from the target id. */ + private fun resolveTargetKind(raw: String?, target: String): TaskTargetKind? = + if (raw != null) TaskTargetKind.entries.firstOrNull { it.name == raw.uppercase() } + else inferTargetKind(target) + + private fun inferTargetKind(target: String): TaskTargetKind = + if (ADR_ID.matches(target) || target.endsWith(".md")) TaskTargetKind.DOC else TaskTargetKind.TASK + + private fun fail(request: ToolRequest, reason: String): ToolResult.Failure = + ToolResult.Failure(request.invocationId, reason, recoverable = false) +} diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/ToolParams.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/ToolParams.kt new file mode 100644 index 00000000..c8968a9c --- /dev/null +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/ToolParams.kt @@ -0,0 +1,11 @@ +package com.correx.infrastructure.tools.task + +import com.correx.core.events.events.ToolRequest + +/** A non-blank string parameter, or null when absent/blank/not a string. */ +internal fun ToolRequest.stringParam(name: String): String? = + (parameters[name] as? String)?.takeIf { it.isNotBlank() } + +/** A string-list parameter (JSON array), coerced element-wise; empty when absent. */ +internal fun ToolRequest.listParam(name: String): List = + (parameters[name] as? List<*>)?.mapNotNull { it?.toString()?.takeIf(String::isNotBlank) } ?: emptyList() diff --git a/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt b/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt new file mode 100644 index 00000000..888afb86 --- /dev/null +++ b/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt @@ -0,0 +1,168 @@ +package com.correx.infrastructure.tools.task + +import com.correx.core.events.events.NewEvent +import com.correx.core.events.events.StoredEvent +import com.correx.core.events.events.ToolRequest +import com.correx.core.events.stores.EventStore +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.events.types.ToolInvocationId +import com.correx.core.tasks.TaskContextAssembler +import com.correx.core.tasks.TaskService +import com.correx.core.tasks.TaskStatus +import com.correx.core.tools.contract.ToolResult +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class TaskToolsTest { + + private val service = TaskService(InMemoryEventStore()) + private val create = TaskCreateTool(service) + private val update = TaskUpdateTool(service) + private val delete = TaskDeleteTool(service) + private val context = TaskContextTool(TaskContextAssembler(service)) + + private var counter = 0 + private fun request(tool: String, params: Map) = ToolRequest( + invocationId = ToolInvocationId("inv-${counter++}"), + sessionId = SessionId("s-1"), + stageId = StageId("stage-1"), + toolName = tool, + parameters = params, + ) + + private suspend fun createTask(): String { + val result = create.execute( + request("task_create", mapOf("project" to "auth", "title" to "JWT refresh", "goal" to "stay authed")), + ) + assertTrue(result is ToolResult.Success) + return (result as ToolResult.Success).metadata.getValue("taskId") + } + + @Test + fun `task_create creates a task and returns its id`() = runBlocking { + val id = createTask() + assertEquals("auth-1", id) + assertEquals(TaskStatus.TODO, service.getTask(com.correx.core.events.types.TaskId(id))?.state?.status) + } + + @Test + fun `task_create rejects missing required fields`() = runBlocking { + val result = create.execute(request("task_create", mapOf("project" to "auth"))) + assertTrue(result is ToolResult.Failure) + } + + @Test + fun `task_update applies status action, edits, link and note`() = runBlocking { + val id = createTask() + val taskId = com.correx.core.events.types.TaskId(id) + + val result = update.execute( + request( + "task_update", + mapOf( + "id" to id, + "action" to "claim", + "claimant" to "claude-opus", + "title" to "JWT refresh flow", + "link_target" to "adr-7", + "link_type" to "implements", + "note" to "starting", + ), + ), + ) + + assertTrue(result is ToolResult.Success) + val state = service.getTask(taskId)!!.state + assertEquals(TaskStatus.IN_PROGRESS, state.status) + assertEquals("claude-opus", state.claimant) + assertEquals("JWT refresh flow", state.title) + assertEquals("adr-7", state.links.single().targetId) + assertEquals("starting", state.notes.single().body) + } + + @Test + fun `task_update rejects an unknown action`() = runBlocking { + val id = createTask() + val result = update.execute(request("task_update", mapOf("id" to id, "action" to "frobnicate"))) + assertTrue(result is ToolResult.Failure) + } + + @Test + fun `task_update on a missing task fails`() = runBlocking { + val result = update.execute(request("task_update", mapOf("id" to "auth-999", "title" to "x"))) + assertTrue(result is ToolResult.Failure) + } + + @Test + fun `task_context returns a rendered bundle with dependencies`() = runBlocking { + val id = createTask() + update.execute( + request("task_update", mapOf("id" to id, "link_target" to "adr-7", "link_type" to "implements")), + ) + + val result = context.execute(request("task_context", mapOf("id" to id))) + + assertTrue(result is ToolResult.Success) + val output = (result as ToolResult.Success).output + assertTrue(output.startsWith("task $id [TODO]")) + assertTrue(output.contains("adr-7 (IMPLEMENTS)")) + } + + @Test + fun `task_context on a missing task fails`() = runBlocking { + assertTrue(context.execute(request("task_context", mapOf("id" to "auth-999"))) is ToolResult.Failure) + } + + @Test + fun `task_delete tombstones the task`() = runBlocking { + val id = createTask() + val deleted = delete.execute(request("task_delete", mapOf("id" to id))) + + assertTrue(deleted is ToolResult.Success) + assertNull(service.getTask(com.correx.core.events.types.TaskId(id))) + // Deleting again now fails: it is gone from active views. + assertTrue(delete.execute(request("task_delete", mapOf("id" to id))) is ToolResult.Failure) + } +} + +/** Minimal append-capable in-memory store for tool tests. */ +private class InMemoryEventStore : EventStore { + private val events = mutableListOf() + private var global = 0L + private val perSession = mutableMapOf() + + override suspend fun append(event: NewEvent): StoredEvent { + global += 1 + val seq = (perSession[event.metadata.sessionId] ?: 0L) + 1 + perSession[event.metadata.sessionId] = seq + val stored = StoredEvent(event.metadata, global, seq, event.payload) + events += stored + return stored + } + + override suspend fun appendAll(events: List): List = events.map { append(it) } + + override fun read(sessionId: SessionId): List = + events.filter { it.metadata.sessionId == sessionId }.sortedBy { it.sessionSequence } + + override fun readFrom(sessionId: SessionId, fromSequence: Long): List = + read(sessionId).filter { it.sessionSequence >= fromSequence } + + override fun lastSequence(sessionId: SessionId): Long? = read(sessionId).maxOfOrNull { it.sessionSequence } + + override fun subscribe(sessionId: SessionId): Flow = emptyFlow() + + override fun subscribeAll(): Flow = emptyFlow() + + override suspend fun lastGlobalSequence(): Long = global + + override fun allEvents(): Sequence = events.asSequence() + + override fun allSessionIds(): Set = events.map { it.metadata.sessionId }.toSet() +} diff --git a/settings.gradle b/settings.gradle index 6fabc8fd..8026899a 100644 --- a/settings.gradle +++ b/settings.gradle @@ -23,6 +23,7 @@ include ':core:tools' include ':core:toolintent' include ':core:router' include ':core:sessions' +include ':core:tasks' include ':core:config' include ':core:risk' include ':core:journal' From 6e044d0ab9e6ade8ee6f70978493035351fcfd05 Mon Sep 17 00:00:00 2001 From: kami Date: Tue, 23 Jun 2026 19:27:19 +0000 Subject: [PATCH 075/107] feat(tasks): full REST surface for tasks CRUD, lifecycle transitions, work-graph links and notes over /tasks; share link-kind inference (TaskTargetKinds) between the REST route and task_update tool. End-to-end route tests. Co-Authored-By: Claude Opus 4.8 --- .../correx/apps/server/routes/TaskRoutes.kt | 238 +++++++++++++- .../apps/server/routes/TaskRoutesTest.kt | 310 ++++++++++++++++++ .../com/correx/core/tasks/TaskTargetKinds.kt | 16 + .../tools/task/TaskUpdateTool.kt | 8 +- 4 files changed, 555 insertions(+), 17 deletions(-) create mode 100644 apps/server/src/test/kotlin/com/correx/apps/server/routes/TaskRoutesTest.kt create mode 100644 core/tasks/src/main/kotlin/com/correx/core/tasks/TaskTargetKinds.kt diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt b/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt index 9e91b282..1df939a1 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt @@ -1,33 +1,249 @@ package com.correx.apps.server.routes import com.correx.apps.server.ServerModule +import com.correx.core.events.types.ProjectId +import com.correx.core.events.types.TaskId +import com.correx.core.events.types.TaskLinkType +import com.correx.core.events.types.TaskNoteAuthor +import com.correx.core.events.types.TaskTargetKind +import com.correx.core.tasks.Task import com.correx.core.tasks.TaskContextAssembler import com.correx.core.tasks.TaskService +import com.correx.core.tasks.TaskStatus +import com.correx.core.tasks.TaskTargetKinds import com.correx.core.utils.TypeId import io.ktor.http.HttpStatusCode +import io.ktor.server.request.receive import io.ktor.server.response.respond import io.ktor.server.routing.Route +import io.ktor.server.routing.RoutingContext +import io.ktor.server.routing.delete import io.ktor.server.routing.get +import io.ktor.server.routing.patch +import io.ktor.server.routing.post import io.ktor.server.routing.route +import kotlinx.serialization.Serializable + +@Serializable +data class TaskLinkDto(val targetId: String, val type: String, val targetKind: String) + +@Serializable +data class TaskNoteDto(val author: String, val body: String, val at: String) + +@Serializable +data class TaskResponse( + val id: String, + val key: String?, + val status: String, + val title: String?, + val goal: String?, + val acceptanceCriteria: List, + val affectedPaths: List, + val claimant: String?, + val links: List, + val notes: List, + val createdAt: String?, + val updatedAt: String?, +) + +@Serializable +data class CreateTaskRequest( + val project: String, + val title: String, + val goal: String, + val acceptanceCriteria: List = emptyList(), + val affectedPaths: List = emptyList(), +) + +// Null fields are left untouched; a present list (even empty) replaces the stored one. +@Serializable +data class EditTaskRequest( + val title: String? = null, + val goal: String? = null, + val acceptanceCriteria: List? = null, + val affectedPaths: List? = null, +) + +@Serializable +data class ClaimRequest(val claimant: String) + +@Serializable +data class BlockRequest(val reason: String) + +@Serializable +data class CancelRequest(val reason: String? = null) + +@Serializable +data class LinkRequest(val targetId: String, val type: String = "RELATES_TO", val targetKind: String? = null) + +@Serializable +data class NoteRequest(val body: String, val author: String? = null) /** - * Read surface for tasks. Exposes the agent context bundle (`GET /tasks/{id}/context`) for - * external agents (Claude Code / aider / codex). In-process roles use the `task_context` tool, - * which shares the same [TaskContextAssembler]. Both read from the event log; nothing is stored. + * Full REST surface for tasks. The write path mirrors the [TaskService] / `task_*` tools; both + * append to the event log, which stays the single source of truth (nothing is stored separately). + * A task's project is encoded in its id, so item routes need only the `{id}`; the collection + * routes take a `project` query/body field. [GET /tasks/{id}/context] serves the agent bundle. */ fun Route.taskRoutes(module: ServerModule) { + val service = TaskService(module.eventStore) val assembler = TaskContextAssembler( - TaskService(module.eventStore), + service, module.taskKnowledgeRetriever, module.taskDocumentResolver, ) - route("/tasks/{id}") { - get("/context") { - val id = call.parameters["id"] - ?: return@get call.respond(HttpStatusCode.BadRequest, "Missing task id") - val bundle = assembler.assemble(TypeId(id)) - ?: return@get call.respond(HttpStatusCode.NotFound, "Task not found") - call.respond(bundle) + route("/tasks") { + taskCollectionRoutes(service) + route("/{id}") { + taskCrudRoutes(service, assembler) + taskTransitionRoutes(service) + taskGraphRoutes(service) } } } + +private fun Route.taskCollectionRoutes(service: TaskService) { + get { + val project = call.request.queryParameters["project"] + ?: return@get call.respond(HttpStatusCode.BadRequest, "Missing 'project' query parameter") + val statusRaw = call.request.queryParameters["status"] + val statusFilter = statusRaw?.let { + parseEnum(it) ?: return@get call.respond(HttpStatusCode.BadRequest, "Invalid status: $it") + } + val tasks = service.list(ProjectId(project)) + .filter { statusFilter == null || it.state.status == statusFilter } + .map { it.toResponse() } + call.respond(tasks) + } + post { + val body = call.receive() + val task = service.createTask( + projectId = ProjectId(body.project), + title = body.title, + goal = body.goal, + acceptanceCriteria = body.acceptanceCriteria, + affectedPaths = body.affectedPaths, + ) + call.respond(HttpStatusCode.Created, task.toResponse()) + } +} + +private fun Route.taskCrudRoutes(service: TaskService, assembler: TaskContextAssembler) { + get { + val id = taskId() ?: return@get call.respond(HttpStatusCode.BadRequest, "Missing task id") + respondTask(service.getTask(id)) + } + patch { + val id = taskId() ?: return@patch call.respond(HttpStatusCode.BadRequest, "Missing task id") + if (service.getTask(id) == null) return@patch call.respond(HttpStatusCode.NotFound, "Task not found") + val body = call.receive() + if (body.title != null || body.goal != null) service.edit(id, body.title, body.goal) + if (body.acceptanceCriteria != null) service.setAcceptanceCriteria(id, body.acceptanceCriteria) + if (body.affectedPaths != null) service.setAffectedPaths(id, body.affectedPaths) + respondTask(service.getTask(id)) + } + delete { + val id = taskId() ?: return@delete call.respond(HttpStatusCode.BadRequest, "Missing task id") + if (service.delete(id)) call.respond(HttpStatusCode.NoContent) + else call.respond(HttpStatusCode.NotFound, "Task not found") + } + get("/context") { + val id = taskId() ?: return@get call.respond(HttpStatusCode.BadRequest, "Missing task id") + val bundle = assembler.assemble(id) ?: return@get call.respond(HttpStatusCode.NotFound, "Task not found") + call.respond(bundle) + } +} + +private fun Route.taskTransitionRoutes(service: TaskService) { + post("/claim") { + val id = taskId() ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing task id") + respondTask(service.claim(id, call.receive().claimant)) + } + post("/release") { + val id = taskId() ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing task id") + respondTask(service.release(id)) + } + post("/block") { + val id = taskId() ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing task id") + respondTask(service.block(id, call.receive().reason)) + } + post("/unblock") { + val id = taskId() ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing task id") + respondTask(service.unblock(id)) + } + post("/submit-for-review") { + val id = taskId() ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing task id") + respondTask(service.submitForReview(id)) + } + post("/complete") { + val id = taskId() ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing task id") + respondTask(service.complete(id)) + } + post("/reopen") { + val id = taskId() ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing task id") + respondTask(service.reopen(id)) + } + post("/cancel") { + val id = taskId() ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing task id") + val reason = runCatching { call.receive() }.getOrNull()?.reason + respondTask(service.cancel(id, reason)) + } +} + +private fun Route.taskGraphRoutes(service: TaskService) { + post("/links") { + val id = taskId() ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing task id") + val body = call.receive() + val type = parseEnum(body.type) + ?: return@post call.respond(HttpStatusCode.BadRequest, "Invalid link type: ${body.type}") + val kind = when (val raw = body.targetKind) { + null -> TaskTargetKinds.infer(body.targetId) + else -> parseEnum(raw) + ?: return@post call.respond(HttpStatusCode.BadRequest, "Invalid target kind: $raw") + } + respondTask(service.link(id, body.targetId, type, kind)) + } + delete("/links") { + val id = taskId() ?: return@delete call.respond(HttpStatusCode.BadRequest, "Missing task id") + val targetId = call.request.queryParameters["targetId"] + ?: return@delete call.respond(HttpStatusCode.BadRequest, "Missing 'targetId' query parameter") + val typeRaw = call.request.queryParameters["type"] + ?: return@delete call.respond(HttpStatusCode.BadRequest, "Missing 'type' query parameter") + val type = parseEnum(typeRaw) + ?: return@delete call.respond(HttpStatusCode.BadRequest, "Invalid link type: $typeRaw") + respondTask(service.unlink(id, targetId, type)) + } + post("/notes") { + val id = taskId() ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing task id") + val body = call.receive() + val author = body.author?.let { + parseEnum(it) ?: return@post call.respond(HttpStatusCode.BadRequest, "Invalid author: $it") + } ?: TaskNoteAuthor.HUMAN + respondTask(service.addNote(id, author, body.body)) + } +} + +private fun RoutingContext.taskId(): TaskId? = call.parameters["id"]?.let { TypeId(it) } + +private suspend fun RoutingContext.respondTask(task: Task?) { + if (task == null) call.respond(HttpStatusCode.NotFound, "Task not found") + else call.respond(task.toResponse()) +} + +private inline fun > parseEnum(raw: String): T? = + enumValues().firstOrNull { it.name.equals(raw, ignoreCase = true) } + +private fun Task.toResponse(): TaskResponse = TaskResponse( + id = taskId.value, + key = state.key, + status = state.status.name, + title = state.title, + goal = state.goal, + acceptanceCriteria = state.acceptanceCriteria, + affectedPaths = state.affectedPaths, + claimant = state.claimant, + links = state.links.map { TaskLinkDto(it.targetId, it.type.name, it.targetKind.name) }, + notes = state.notes.map { TaskNoteDto(it.author.name, it.body, it.at.toString()) }, + createdAt = state.createdAt?.toString(), + updatedAt = state.updatedAt?.toString(), +) diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/routes/TaskRoutesTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/routes/TaskRoutesTest.kt new file mode 100644 index 00000000..2ae7aeae --- /dev/null +++ b/apps/server/src/test/kotlin/com/correx/apps/server/routes/TaskRoutesTest.kt @@ -0,0 +1,310 @@ +package com.correx.apps.server.routes + +import com.correx.apps.server.ServerModule +import com.correx.apps.server.configureServer +import com.correx.apps.server.registry.ProviderRegistry +import com.correx.apps.server.registry.WorkflowRegistry +import com.correx.apps.server.registry.WorkflowSummary +import com.correx.apps.server.undo.SessionUndoService +import com.correx.core.artifactstore.ArtifactStore +import com.correx.core.context.builder.DefaultContextPackBuilder +import com.correx.core.context.compression.DefaultContextCompressor +import com.correx.core.events.EventDispatcher +import com.correx.core.events.stores.EventStore +import com.correx.core.events.types.ArtifactId +import com.correx.core.inference.InferenceProjector +import com.correx.core.inference.InferenceProvider +import com.correx.core.inference.InferenceRepository +import com.correx.core.inference.ProviderHealth +import com.correx.core.kernel.orchestration.DefaultOrchestrationReducer +import com.correx.core.kernel.orchestration.DefaultSessionOrchestrator +import com.correx.core.kernel.orchestration.OrchestrationConfig +import com.correx.core.kernel.orchestration.OrchestrationProjector +import com.correx.core.kernel.orchestration.OrchestrationRepository +import com.correx.core.kernel.orchestration.OrchestratorEngines +import com.correx.core.kernel.orchestration.OrchestratorRepositories +import com.correx.core.kernel.retry.DefaultRetryCoordinator +import com.correx.core.events.types.ProviderId +import com.correx.core.risk.DefaultRiskAssessor +import com.correx.core.router.model.RouterConfig +import com.correx.core.sessions.DefaultSessionReducer +import com.correx.core.sessions.DefaultSessionRepository +import com.correx.core.sessions.SessionProjector +import com.correx.core.sessions.projections.replay.DefaultEventReplayer +import com.correx.core.transitions.graph.WorkflowGraph +import com.correx.core.transitions.resolution.DefaultTransitionResolver +import com.correx.core.validation.pipeline.ValidationPipeline +import com.correx.infrastructure.InfrastructureModule +import com.correx.infrastructure.inference.commons.UnavailableProbe +import com.correx.infrastructure.persistence.InMemoryEventStore +import com.correx.infrastructure.tools.FileEditConfig +import com.correx.infrastructure.tools.FileReadConfig +import com.correx.infrastructure.tools.FileWriteConfig +import com.correx.infrastructure.tools.ShellConfig +import com.correx.infrastructure.tools.ToolConfig +import io.ktor.client.HttpClient +import io.ktor.client.request.delete +import io.ktor.client.request.get +import io.ktor.client.request.patch +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.HttpResponse +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType +import io.ktor.server.testing.testApplication +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonPrimitive +import org.junit.jupiter.api.Assertions.assertEquals +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 TaskRoutesTest { + + private val testJson = Json { ignoreUnknownKeys = true } + + private suspend fun HttpResponse.json(): JsonObject = + testJson.parseToJsonElement(bodyAsText()) as JsonObject + + private suspend fun HttpClient.createTask(project: String = "demo"): JsonObject = + post("/tasks") { + contentType(ContentType.Application.Json) + setBody("""{"project":"$project","title":"JWT refresh","goal":"users stay authed"}""") + }.json() + + @Test + fun `POST creates a task and GET lists and fetches it`(@TempDir tempDir: Path) { + testApplication { + application { configureServer(buildModule(tempDir)) } + val client = createClient {} + + val created = client.post("/tasks") { + contentType(ContentType.Application.Json) + setBody( + """{"project":"demo","title":"JWT refresh","goal":"stay authed", + "acceptanceCriteria":["rotates"],"affectedPaths":["auth/**"]}""", + ) + } + assertEquals(HttpStatusCode.Created, created.status) + val body = created.json() + assertEquals("demo-1", body["id"]!!.jsonPrimitive.content) + assertEquals("TODO", body["status"]!!.jsonPrimitive.content) + assertEquals("rotates", body["acceptanceCriteria"]!!.jsonArray.single().jsonPrimitive.content) + + val list = client.get("/tasks?project=demo") + assertEquals(HttpStatusCode.OK, list.status) + val rows = testJson.parseToJsonElement(list.bodyAsText()) as JsonArray + assertEquals(1, rows.size) + assertEquals("demo-1", (rows[0] as JsonObject)["id"]!!.jsonPrimitive.content) + + val fetched = client.get("/tasks/demo-1") + assertEquals(HttpStatusCode.OK, fetched.status) + assertEquals("JWT refresh", fetched.json()["title"]!!.jsonPrimitive.content) + } + } + + @Test + fun `lifecycle transitions move a task to DONE`(@TempDir tempDir: Path) { + testApplication { + application { configureServer(buildModule(tempDir)) } + val client = createClient {} + client.createTask() + + val claimed = client.post("/tasks/demo-1/claim") { + contentType(ContentType.Application.Json) + setBody("""{"claimant":"claude-opus"}""") + } + assertEquals("IN_PROGRESS", claimed.json()["status"]!!.jsonPrimitive.content) + assertEquals("claude-opus", claimed.json()["claimant"]!!.jsonPrimitive.content) + + assertEquals("IN_REVIEW", client.post("/tasks/demo-1/submit-for-review").json()["status"]!!.jsonPrimitive.content) + assertEquals("DONE", client.post("/tasks/demo-1/complete").json()["status"]!!.jsonPrimitive.content) + } + } + + @Test + fun `PATCH edits fields and POST links and notes mutate the graph`(@TempDir tempDir: Path) { + testApplication { + application { configureServer(buildModule(tempDir)) } + val client = createClient {} + client.createTask() + + val patched = client.patch("/tasks/demo-1") { + contentType(ContentType.Application.Json) + setBody("""{"title":"JWT refresh v2"}""") + } + assertEquals("JWT refresh v2", patched.json()["title"]!!.jsonPrimitive.content) + + // No explicit kind: an ADR id infers to DOC. + val linked = client.post("/tasks/demo-1/links") { + contentType(ContentType.Application.Json) + setBody("""{"targetId":"adr-7","type":"IMPLEMENTS"}""") + } + val link = linked.json()["links"]!!.jsonArray.single() as JsonObject + assertEquals("adr-7", link["targetId"]!!.jsonPrimitive.content) + assertEquals("IMPLEMENTS", link["type"]!!.jsonPrimitive.content) + assertEquals("DOC", link["targetKind"]!!.jsonPrimitive.content) + + val noted = client.post("/tasks/demo-1/notes") { + contentType(ContentType.Application.Json) + setBody("""{"body":"kickoff"}""") + } + val note = noted.json()["notes"]!!.jsonArray.single() as JsonObject + assertEquals("kickoff", note["body"]!!.jsonPrimitive.content) + assertEquals("HUMAN", note["author"]!!.jsonPrimitive.content) + + // Unlink via query params clears the edge. + val unlinked = client.delete("/tasks/demo-1/links?targetId=adr-7&type=IMPLEMENTS") + assertTrue(unlinked.json()["links"]!!.jsonArray.isEmpty()) + } + } + + @Test + fun `DELETE soft-deletes and the task disappears from reads`(@TempDir tempDir: Path) { + testApplication { + application { configureServer(buildModule(tempDir)) } + val client = createClient {} + client.createTask() + + assertEquals(HttpStatusCode.NoContent, client.delete("/tasks/demo-1").status) + assertEquals(HttpStatusCode.NotFound, client.get("/tasks/demo-1").status) + assertEquals(HttpStatusCode.NotFound, client.delete("/tasks/demo-1").status) + } + } + + @Test + fun `context bundle is served for a known task`(@TempDir tempDir: Path) { + testApplication { + application { configureServer(buildModule(tempDir)) } + val client = createClient {} + client.createTask() + + val bundle = client.get("/tasks/demo-1/context") + assertEquals(HttpStatusCode.OK, bundle.status) + assertEquals("demo-1", bundle.json()["id"]!!.jsonPrimitive.content) + assertEquals(HttpStatusCode.NotFound, client.get("/tasks/demo-999/context").status) + } + } + + @Test + fun `bad requests are rejected`(@TempDir tempDir: Path) { + testApplication { + application { configureServer(buildModule(tempDir)) } + val client = createClient {} + client.createTask() + + assertEquals(HttpStatusCode.BadRequest, client.get("/tasks").status) + assertEquals(HttpStatusCode.NotFound, client.get("/tasks/nope-1").status) + val badLink = client.post("/tasks/demo-1/links") { + contentType(ContentType.Application.Json) + setBody("""{"targetId":"x","type":"NONSENSE"}""") + } + assertEquals(HttpStatusCode.BadRequest, badLink.status) + } + } + + // --- harness ---------------------------------------------------------------------------- + + private fun buildModule(tempDir: Path): ServerModule { + val eventStore: EventStore = InMemoryEventStore() + val artifactStore = object : ArtifactStore { + override suspend fun put(bytes: ByteArray): ArtifactId = ArtifactId("noop") + override suspend fun get(id: ArtifactId): ByteArray? = null + override suspend fun flushBefore(commit: suspend () -> Unit) = commit() + } + val provider = InfrastructureModule.createLlamaCppProvider( + modelId = "test-model", + modelPath = "/dev/null", + baseUrl = "http://127.0.0.1:1", + ) + val providerRegistry = InfrastructureModule.createProviderRegistry(listOf(provider)) + val inferenceRouter = com.correx.core.inference.DefaultInferenceRouter( + providerRegistry, + com.correx.infrastructure.inference.FirstAvailableRoutingStrategy(), + ) + val toolConfig = ToolConfig( + shell = ShellConfig(enabled = false, allowedExecutables = emptySet(), workingDir = tempDir), + fileRead = FileReadConfig(enabled = false, allowedPaths = setOf(tempDir)), + fileWrite = FileWriteConfig(enabled = false, allowedPaths = setOf(tempDir), workingDir = tempDir), + fileEdit = FileEditConfig(enabled = false, allowedPaths = setOf(tempDir), workingDir = tempDir), + ) + val toolRegistry = InfrastructureModule.createToolRegistry(toolConfig) + val eventDispatcher = EventDispatcher(eventStore) + val toolExecutor = InfrastructureModule.createToolExecutor( + registry = toolRegistry, + eventDispatcher = eventDispatcher, + workDir = tempDir, + artifactStore = null, + ) + val engines = OrchestratorEngines( + transitionResolver = DefaultTransitionResolver { condition, ctx -> condition.evaluate(ctx) }, + contextPackBuilder = DefaultContextPackBuilder(DefaultContextCompressor()), + inferenceRouter = inferenceRouter, + validationPipeline = ValidationPipeline(validators = emptyList()), + approvalEngine = com.correx.core.approvals.domain.DefaultApprovalEngine(), + riskAssessor = DefaultRiskAssessor(), + toolRegistry = toolRegistry, + toolExecutor = toolExecutor, + ) + val repositories = OrchestratorRepositories( + eventStore = eventStore, + inferenceRepository = InferenceRepository(DefaultEventReplayer(eventStore, InferenceProjector())), + orchestrationRepository = OrchestrationRepository( + DefaultEventReplayer(eventStore, OrchestrationProjector(DefaultOrchestrationReducer())), + ), + sessionRepository = DefaultSessionRepository( + DefaultEventReplayer(eventStore, SessionProjector(DefaultSessionReducer())), + ), + artifactRepository = InfrastructureModule.createArtifactRepository(eventStore), + approvalRepository = InfrastructureModule.createApprovalRepository(eventStore), + ) + val orchestrator = DefaultSessionOrchestrator( + repositories = repositories, + engines = engines, + retryCoordinator = DefaultRetryCoordinator(eventStore), + artifactStore = artifactStore, + tokenizer = provider.tokenizer, + decisionJournalRepository = InfrastructureModule.createDecisionJournalRepository(eventStore), + ) + val routerFacade = InfrastructureModule.createRouterFacade( + eventStore = eventStore, + inferenceRouter = inferenceRouter, + config = RouterConfig(), + tokenizer = provider.tokenizer, + ) + val noopProviderRegistry = object : ProviderRegistry { + override fun listAll() = emptyList() + override suspend fun healthCheckAll() = emptyMap() + } + val noopWorkflowRegistry = object : WorkflowRegistry { + override fun listAll(): List = emptyList() + override fun find(workflowId: String): WorkflowGraph? = null + } + val sessionUndoService = SessionUndoService( + eventStore = eventStore, + artifactStore = artifactStore, + bootRoots = setOf(tempDir), + ) + return ServerModule( + orchestrator = orchestrator, + eventStore = eventStore, + artifactStore = artifactStore, + sessionRepository = repositories.sessionRepository, + workflowRegistry = noopWorkflowRegistry, + providerRegistry = noopProviderRegistry, + defaultOrchestrationConfig = OrchestrationConfig(sandboxRoot = tempDir), + routerFacade = routerFacade, + orchestrationRepository = repositories.orchestrationRepository, + approvalRepository = repositories.approvalRepository, + toolRegistry = toolRegistry, + sessionUndoService = sessionUndoService, + resourceProbe = UnavailableProbe, + ) + } +} diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskTargetKinds.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskTargetKinds.kt new file mode 100644 index 00000000..8a163cff --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskTargetKinds.kt @@ -0,0 +1,16 @@ +package com.correx.core.tasks + +import com.correx.core.events.types.TaskTargetKind + +/** + * Infers what a work-graph link points at from its target id, for callers that don't say + * explicitly. ADR ids (`adr-12`, `ADR_0007`) and `.md` paths are documents; everything else is + * assumed to be another task. Shared by the agent tool and the REST surface so the heuristic + * stays in one place. Always prefer an explicit [TaskTargetKind] when the caller provides one. + */ +object TaskTargetKinds { + private val ADR_ID = Regex("(?i)^adr[-_ ]?\\d+$") + + fun infer(targetId: String): TaskTargetKind = + if (ADR_ID.matches(targetId) || targetId.endsWith(".md")) TaskTargetKind.DOC else TaskTargetKind.TASK +} diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt index 15a8f447..80bdcb9c 100644 --- a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt @@ -7,6 +7,7 @@ import com.correx.core.events.types.TaskLinkType import com.correx.core.events.types.TaskNoteAuthor import com.correx.core.events.types.TaskTargetKind import com.correx.core.tasks.TaskService +import com.correx.core.tasks.TaskTargetKinds import com.correx.core.tools.contract.Tool import com.correx.core.tools.contract.ToolCapability import com.correx.core.tools.contract.ToolExecutor @@ -20,8 +21,6 @@ import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put import kotlinx.serialization.json.putJsonObject -private val ADR_ID = Regex("(?i)^adr[-_ ]?\\d+$") - /** * Agent-facing tool: update an existing task — edit fields, transition status, add a work-graph * link, or attach a note. The task's project is derived from its id, so only the id is required. @@ -148,10 +147,7 @@ class TaskUpdateTool(private val service: TaskService) : Tool, ToolExecutor { /** Explicit kind when given (null if unrecognised); otherwise inferred from the target id. */ private fun resolveTargetKind(raw: String?, target: String): TaskTargetKind? = if (raw != null) TaskTargetKind.entries.firstOrNull { it.name == raw.uppercase() } - else inferTargetKind(target) - - private fun inferTargetKind(target: String): TaskTargetKind = - if (ADR_ID.matches(target) || target.endsWith(".md")) TaskTargetKind.DOC else TaskTargetKind.TASK + else TaskTargetKinds.infer(target) private fun fail(request: ToolRequest, reason: String): ToolResult.Failure = ToolResult.Failure(request.invocationId, reason, recoverable = false) From 6b00d89c2c14b361946afac671d67c7cfe914c03 Mon Sep 17 00:00:00 2001 From: kami Date: Tue, 23 Jun 2026 19:59:18 +0000 Subject: [PATCH 076/107] feat(tasks): inline ARTIFACT/SESSION links in the context bundle The context bundle resolved only TASK and DOC link targets; ARTIFACT and SESSION stayed raw. Add two ports (TaskArtifactResolver/TaskSessionResolver) with apps/server adapters: artifacts resolve to producing stage/session + content excerpt from CAS, sessions to status/intent/workflow. Unresolved targets still fall back to raw links. Wired through the tool and REST bundle. Co-Authored-By: Claude Opus 4.8 --- .../kotlin/com/correx/apps/server/Main.kt | 16 ++- .../com/correx/apps/server/ServerModule.kt | 4 + .../correx/apps/server/routes/TaskRoutes.kt | 2 + .../tasks/EventStoreTaskArtifactResolver.kt | 53 ++++++++++ .../SessionSummaryTaskSessionResolver.kt | 33 +++++++ .../apps/server/tasks/TaskLinkResolverTest.kt | 83 ++++++++++++++++ .../correx/core/tasks/TaskArtifactResolver.kt | 21 ++++ .../correx/core/tasks/TaskContextAssembler.kt | 98 ++++++++++++------- .../correx/core/tasks/TaskContextBundle.kt | 24 +++++ .../correx/core/tasks/TaskSessionResolver.kt | 21 ++++ .../core/tasks/TaskContextAssemblerTest.kt | 35 +++++++ .../infrastructure/tools/task/TaskTools.kt | 13 ++- 12 files changed, 364 insertions(+), 39 deletions(-) create mode 100644 apps/server/src/main/kotlin/com/correx/apps/server/tasks/EventStoreTaskArtifactResolver.kt create mode 100644 apps/server/src/main/kotlin/com/correx/apps/server/tasks/SessionSummaryTaskSessionResolver.kt create mode 100644 apps/server/src/test/kotlin/com/correx/apps/server/tasks/TaskLinkResolverTest.kt create mode 100644 core/tasks/src/main/kotlin/com/correx/core/tasks/TaskArtifactResolver.kt create mode 100644 core/tasks/src/main/kotlin/com/correx/core/tasks/TaskSessionResolver.kt diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt index 2524047f..3659d831 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt @@ -221,8 +221,20 @@ fun main() { // Inlines linked ADRs/docs into the context bundle. The repo root is known here, so unlike the // L3 retriever this needs no late binding. val taskDocumentResolver = com.correx.apps.server.tasks.FileTaskDocumentResolver(workspaceRoot) + // Inline ARTIFACT/SESSION link targets too: both read the event log (+ CAS) that already exists + // here, so — like the doc resolver — they need no late binding. + val taskArtifactResolver = + com.correx.apps.server.tasks.EventStoreTaskArtifactResolver(eventStore, artifactStore) + val taskSessionResolver = + com.correx.apps.server.tasks.SessionSummaryTaskSessionResolver(eventStore) val taskService = TaskService(eventStore) - val taskTools = TaskTools.forService(taskService, taskKnowledgeRetriever, taskDocumentResolver) + val taskTools = TaskTools.forService( + taskService, + taskKnowledgeRetriever, + taskDocumentResolver, + taskArtifactResolver, + taskSessionResolver, + ) val toolRegistry = InfrastructureModule.createToolRegistry( buildToolConfig( workspaceRoot, @@ -521,6 +533,8 @@ fun main() { healthMonitor = healthMonitor, taskKnowledgeRetriever = taskKnowledgeRetriever, taskDocumentResolver = taskDocumentResolver, + taskArtifactResolver = taskArtifactResolver, + taskSessionResolver = taskSessionResolver, ) // Wire live config editing: persist to TOML, swap the holder, and rebuild config-derived // services. Built after the module so the rebuild hook can swap them in place. diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt b/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt index 4293727c..9a95f434 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt @@ -119,6 +119,10 @@ class ServerModule( val taskKnowledgeRetriever: com.correx.core.tasks.TaskKnowledgeRetriever? = null, // Resolves linked ADRs/docs inline in the task context bundle. Null leaves them as raw links. val taskDocumentResolver: com.correx.core.tasks.TaskDocumentResolver? = null, + // Resolves linked artifacts inline in the task context bundle. Null leaves them as raw links. + val taskArtifactResolver: com.correx.core.tasks.TaskArtifactResolver? = null, + // Resolves linked agent sessions inline in the task context bundle. Null leaves them as raw links. + val taskSessionResolver: com.correx.core.tasks.TaskSessionResolver? = null, ) { val approvalCoordinator: ApprovalCoordinator = approvalCoordinator ?: ApprovalCoordinator( orchestrator = orchestrator, diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt b/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt index 1df939a1..93c3d1ba 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt @@ -91,6 +91,8 @@ fun Route.taskRoutes(module: ServerModule) { service, module.taskKnowledgeRetriever, module.taskDocumentResolver, + module.taskArtifactResolver, + module.taskSessionResolver, ) route("/tasks") { taskCollectionRoutes(service) diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/tasks/EventStoreTaskArtifactResolver.kt b/apps/server/src/main/kotlin/com/correx/apps/server/tasks/EventStoreTaskArtifactResolver.kt new file mode 100644 index 00000000..82d97bda --- /dev/null +++ b/apps/server/src/main/kotlin/com/correx/apps/server/tasks/EventStoreTaskArtifactResolver.kt @@ -0,0 +1,53 @@ +package com.correx.apps.server.tasks + +import com.correx.core.artifactstore.ArtifactStore +import com.correx.core.events.events.ArtifactContentStoredEvent +import com.correx.core.events.events.ArtifactCreatedEvent +import com.correx.core.events.stores.EventStore +import com.correx.core.events.types.ArtifactId +import com.correx.core.tasks.ResolvedArtifact +import com.correx.core.tasks.TaskArtifactResolver +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +private const val EXCERPT_MAX = 280 + +/** + * Resolves ARTIFACT link targets for the task context bundle by scanning the event log for the + * artifact's lifecycle events: [ArtifactCreatedEvent]/[ArtifactContentStoredEvent] give the + * producing stage + session, and the content hash is dereferenced against the CAS [ArtifactStore] + * for an excerpt. Returns null when no events match the id — the link then stays a raw related link. + */ +class EventStoreTaskArtifactResolver( + private val eventStore: EventStore, + private val artifactStore: ArtifactStore, + private val excerptMax: Int = EXCERPT_MAX, +) : TaskArtifactResolver { + + override suspend fun resolve(targetId: String): ResolvedArtifact? = withContext(Dispatchers.IO) { + val artifactId = ArtifactId(targetId) + var stage: String? = null + var sessionId: String? = null + var contentHash: ArtifactId? = null + eventStore.allEvents().forEach { stored -> + when (val p = stored.payload) { + is ArtifactCreatedEvent -> if (p.artifactId == artifactId) { + stage = p.stageId.value + sessionId = p.sessionId.value + } + is ArtifactContentStoredEvent -> if (p.artifactId == artifactId) { + stage = stage ?: p.stageId.value + sessionId = sessionId ?: p.sessionId.value + contentHash = p.contentHash + } + else -> Unit + } + } + if (stage == null && sessionId == null && contentHash == null) return@withContext null + val excerpt = contentHash?.let { hash -> + runCatching { artifactStore.get(hash) }.getOrNull() + ?.toString(Charsets.UTF_8)?.trim()?.takeIf { it.isNotEmpty() }?.take(excerptMax) + } + ResolvedArtifact(stage = stage, sessionId = sessionId, excerpt = excerpt) + } +} diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/tasks/SessionSummaryTaskSessionResolver.kt b/apps/server/src/main/kotlin/com/correx/apps/server/tasks/SessionSummaryTaskSessionResolver.kt new file mode 100644 index 00000000..a58ef693 --- /dev/null +++ b/apps/server/src/main/kotlin/com/correx/apps/server/tasks/SessionSummaryTaskSessionResolver.kt @@ -0,0 +1,33 @@ +package com.correx.apps.server.tasks + +import com.correx.core.events.stores.EventStore +import com.correx.core.sessions.SessionSummaryProjector +import com.correx.core.tasks.ResolvedSession +import com.correx.core.tasks.TaskSessionResolver +import com.correx.core.utils.TypeId +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Resolves SESSION link targets for the task context bundle by projecting the session's events into + * a [com.correx.core.sessions.SessionSummary] (status / intent / workflow). Returns null when the + * session has no events — the link then stays a raw related link. + */ +class SessionSummaryTaskSessionResolver( + private val eventStore: EventStore, +) : TaskSessionResolver { + + private val projector = SessionSummaryProjector() + + override suspend fun resolve(targetId: String): ResolvedSession? = withContext(Dispatchers.IO) { + val sessionId = TypeId(targetId) + val events = eventStore.read(sessionId) + if (events.isEmpty()) return@withContext null + val summary = projector.project(sessionId, events) + ResolvedSession( + status = summary.status, + intent = summary.intent, + workflowId = summary.workflowId, + ) + } +} diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/tasks/TaskLinkResolverTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/tasks/TaskLinkResolverTest.kt new file mode 100644 index 00000000..5bbaaf71 --- /dev/null +++ b/apps/server/src/test/kotlin/com/correx/apps/server/tasks/TaskLinkResolverTest.kt @@ -0,0 +1,83 @@ +package com.correx.apps.server.tasks + +import com.correx.core.artifactstore.ArtifactStore +import com.correx.core.events.events.ArtifactContentStoredEvent +import com.correx.core.events.events.ArtifactCreatedEvent +import com.correx.core.events.events.EventMetadata +import com.correx.core.events.events.InitialIntentEvent +import com.correx.core.events.events.NewEvent +import com.correx.core.events.events.WorkflowCompletedEvent +import com.correx.core.events.events.WorkflowStartedEvent +import com.correx.core.events.stores.EventStore +import com.correx.core.events.types.ArtifactId +import com.correx.core.events.types.EventId +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.infrastructure.persistence.InMemoryEventStore +import kotlinx.coroutines.runBlocking +import kotlinx.datetime.Clock +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test +import java.util.UUID + +class TaskLinkResolverTest { + + private val store: EventStore = InMemoryEventStore() + + private suspend fun append(sessionId: SessionId, payload: com.correx.core.events.events.EventPayload) { + store.append( + NewEvent( + metadata = EventMetadata( + eventId = EventId(UUID.randomUUID().toString()), + sessionId = sessionId, + timestamp = Clock.System.now(), + schemaVersion = 1, + causationId = null, + correlationId = null, + ), + payload = payload, + ), + ) + } + + @Test + fun `session resolver projects status and intent and is null for an unknown id`() = runBlocking { + val sessionId = SessionId("sess-9") + append(sessionId, InitialIntentEvent(sessionId, "build auth")) + append(sessionId, WorkflowStartedEvent(sessionId, "role_pipeline", StageId("architect"))) + append(sessionId, WorkflowCompletedEvent(sessionId, StageId("review"), totalStages = 3)) + + val resolver = SessionSummaryTaskSessionResolver(store) + val resolved = resolver.resolve("sess-9")!! + + assertEquals("COMPLETED", resolved.status) + assertEquals("build auth", resolved.intent) + assertEquals("role_pipeline", resolved.workflowId) + assertNull(resolver.resolve("sess-unknown")) + } + + @Test + fun `artifact resolver reads stage, session and content excerpt and is null for an unknown id`() = runBlocking { + val sessionId = SessionId("sess-9") + val artifactId = ArtifactId("art-1") + val contentHash = ArtifactId("hash-1") + val artifactStore = FakeArtifactStore(mapOf(contentHash to "approach: use redis".toByteArray())) + append(sessionId, ArtifactContentStoredEvent(artifactId, contentHash, sessionId, StageId("architect"))) + append(sessionId, ArtifactCreatedEvent(artifactId, sessionId, StageId("architect"), schemaVersion = 1)) + + val resolver = EventStoreTaskArtifactResolver(store, artifactStore) + val resolved = resolver.resolve("art-1")!! + + assertEquals("architect", resolved.stage) + assertEquals("sess-9", resolved.sessionId) + assertEquals("approach: use redis", resolved.excerpt) + assertNull(resolver.resolve("art-unknown")) + } + + private class FakeArtifactStore(private val blobs: Map) : ArtifactStore { + override suspend fun put(bytes: ByteArray): ArtifactId = error("unused") + override suspend fun get(id: ArtifactId): ByteArray? = blobs[id] + override suspend fun flushBefore(commit: suspend () -> Unit) = commit() + } +} diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskArtifactResolver.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskArtifactResolver.kt new file mode 100644 index 00000000..547d9e72 --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskArtifactResolver.kt @@ -0,0 +1,21 @@ +package com.correx.core.tasks + +import kotlinx.serialization.Serializable + +/** + * Port for resolving an ARTIFACT link target (an `ArtifactId`) to an inline summary, so the + * context bundle can carry the producing stage/session and a content excerpt instead of a bare id. + * Implemented in a higher layer (apps/server reads the event log + CAS); `core:tasks` stays + * decoupled from storage. Returns null when the artifact can't be resolved — the link then stays a + * raw related link. + */ +interface TaskArtifactResolver { + suspend fun resolve(targetId: String): ResolvedArtifact? +} + +@Serializable +data class ResolvedArtifact( + val stage: String?, + val sessionId: String?, + val excerpt: String?, +) diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskContextAssembler.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskContextAssembler.kt index 3ff9fac6..b7bd3b0b 100644 --- a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskContextAssembler.kt +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskContextAssembler.kt @@ -5,36 +5,36 @@ import com.correx.core.events.types.TaskTargetKind /** * Assembles a [TaskContextBundle] for a task. Links are partitioned by their recorded - * [com.correx.core.events.types.TaskTargetKind]: TASK targets are resolved to [RelatedTask] - * dependencies (with live status), DOC targets are resolved inline via [documentResolver], and - * anything unresolved (or ARTIFACT/SESSION, not inlined yet) becomes a raw [RelatedLink]. + * [com.correx.core.events.types.TaskTargetKind]: TASK targets resolve to [RelatedTask] dependencies + * (with live status), DOC targets inline via [documentResolver], ARTIFACT targets via + * [artifactResolver], SESSION targets via [sessionResolver]. Any kind whose resolver is absent or + * comes up empty falls back to a raw [RelatedLink]. * * When a [TaskKnowledgeRetriever] is supplied, the bundle is additionally enriched with - * semantically-relevant snippets over the task's title+goal. Retrieval and doc-resolution failures - * are swallowed (the bundle still returns) so context assembly never fails on a flaky dependency. + * semantically-relevant snippets over the task's title+goal. Retrieval and resolution failures are + * swallowed (the bundle still returns) so context assembly never fails on a flaky dependency. */ class TaskContextAssembler( private val service: TaskService, private val retriever: TaskKnowledgeRetriever? = null, private val documentResolver: TaskDocumentResolver? = null, + private val artifactResolver: TaskArtifactResolver? = null, + private val sessionResolver: TaskSessionResolver? = null, private val knowledgeLimit: Int = DEFAULT_KNOWLEDGE_LIMIT, ) { suspend fun assemble(taskId: TaskId): TaskContextBundle? { val state = service.getTask(taskId)?.state ?: return null - val dependencies = mutableListOf() - val documents = mutableListOf() - val related = mutableListOf() + val resolved = ResolvedLinks() for (link in state.links) { // Resolution dispatches on the link's recorded targetKind — no guessing from the id. - // A kind whose resolution comes up empty (missing task, unresolvable doc, or an - // ARTIFACT/SESSION we don't inline yet) falls back to a raw related link. + // A kind whose resolution comes up empty falls back to a raw related link. when (link.targetKind) { - TaskTargetKind.TASK -> addTask(link, dependencies, related) - TaskTargetKind.DOC -> addDocument(link, documents, related) - TaskTargetKind.ARTIFACT, TaskTargetKind.SESSION -> - related += RelatedLink(link.targetId, link.type.name) + TaskTargetKind.TASK -> addTask(link, resolved) + TaskTargetKind.DOC -> addDocument(link, resolved) + TaskTargetKind.ARTIFACT -> addArtifact(link, resolved) + TaskTargetKind.SESSION -> addSession(link, resolved) } } @@ -46,40 +46,43 @@ class TaskContextAssembler( goal = state.goal, acceptanceCriteria = state.acceptanceCriteria, relevantFiles = state.affectedPaths, - dependencies = dependencies, - documents = documents, - relatedLinks = related, + dependencies = resolved.dependencies, + documents = resolved.documents, + artifacts = resolved.artifacts, + sessions = resolved.sessions, + relatedLinks = resolved.related, notes = state.notes.map { NoteEntry(it.author.name, it.body) }, relevantKnowledge = retrieveKnowledge(state), ) } - private fun addTask( - link: TaskLink, - dependencies: MutableList, - related: MutableList, - ) { + /** Accumulators for one assembly pass — keeps the per-kind helpers to a single parameter. */ + private class ResolvedLinks { + val dependencies = mutableListOf() + val documents = mutableListOf() + val artifacts = mutableListOf() + val sessions = mutableListOf() + val related = mutableListOf() + } + + private fun addTask(link: TaskLink, out: ResolvedLinks) { val linked = runCatching { service.getTask(TaskId(link.targetId)) }.getOrNull() if (linked != null) { - dependencies += RelatedTask( + out.dependencies += RelatedTask( id = linked.taskId.value, status = linked.state.status.name, title = linked.state.title, link = link.type.name, ) } else { - related += RelatedLink(link.targetId, link.type.name) + out.related += RelatedLink(link.targetId, link.type.name) } } - private suspend fun addDocument( - link: TaskLink, - documents: MutableList, - related: MutableList, - ) { - val doc = resolveDocument(link.targetId) + private suspend fun addDocument(link: TaskLink, out: ResolvedLinks) { + val doc = documentResolver?.let { runCatching { it.resolve(link.targetId) }.getOrNull() } if (doc != null) { - documents += TaskDocument( + out.documents += TaskDocument( targetId = link.targetId, type = link.type.name, title = doc.title, @@ -87,13 +90,38 @@ class TaskContextAssembler( excerpt = doc.excerpt, ) } else { - related += RelatedLink(link.targetId, link.type.name) + out.related += RelatedLink(link.targetId, link.type.name) } } - private suspend fun resolveDocument(targetId: String): ResolvedDocument? { - val resolver = documentResolver ?: return null - return runCatching { resolver.resolve(targetId) }.getOrNull() + private suspend fun addArtifact(link: TaskLink, out: ResolvedLinks) { + val artifact = artifactResolver?.let { runCatching { it.resolve(link.targetId) }.getOrNull() } + if (artifact != null) { + out.artifacts += TaskArtifact( + targetId = link.targetId, + type = link.type.name, + stage = artifact.stage, + sessionId = artifact.sessionId, + excerpt = artifact.excerpt, + ) + } else { + out.related += RelatedLink(link.targetId, link.type.name) + } + } + + private suspend fun addSession(link: TaskLink, out: ResolvedLinks) { + val session = sessionResolver?.let { runCatching { it.resolve(link.targetId) }.getOrNull() } + if (session != null) { + out.sessions += RelatedSession( + targetId = link.targetId, + type = link.type.name, + status = session.status, + intent = session.intent, + workflowId = session.workflowId, + ) + } else { + out.related += RelatedLink(link.targetId, link.type.name) + } } private suspend fun retrieveKnowledge(state: TaskState): List { diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskContextBundle.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskContextBundle.kt index 65d36c3c..db2090e6 100644 --- a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskContextBundle.kt +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskContextBundle.kt @@ -18,6 +18,8 @@ data class TaskContextBundle( val relevantFiles: List, val dependencies: List, val documents: List, + val artifacts: List = emptyList(), + val sessions: List = emptyList(), val relatedLinks: List, val notes: List, val relevantKnowledge: List = emptyList(), @@ -30,6 +32,8 @@ data class TaskContextBundle( if (relevantFiles.isNotEmpty()) appendLine("relevant_files: ${relevantFiles.joinToString(", ")}") appendList("dependencies", dependencies) { " - ${it.id} [${it.status}] ${it.title.orEmpty()} (${it.link})".trimEnd() } appendList("documents", documents) { " - ${it.targetId} (${it.type}) ${it.title} [${it.path}]: ${it.excerpt}" } + appendList("artifacts", artifacts) { " - ${it.targetId} (${it.type})${it.stage?.let { s -> " @$s" }.orEmpty()}: ${it.excerpt.orEmpty()}".trimEnd() } + appendList("sessions", sessions) { " - ${it.targetId} (${it.type}) [${it.status}] ${it.intent.orEmpty()}".trimEnd() } appendList("related", relatedLinks) { " - ${it.targetId} (${it.type})" } appendList("relevant_knowledge", relevantKnowledge) { " - ${it.source}: ${it.text}" } appendList("notes", notes) { " - [${it.author}] ${it.body}" } @@ -61,6 +65,26 @@ data class TaskDocument( val excerpt: String, ) +/** A link whose target resolved to a produced artifact: producing stage/session + content excerpt. */ +@Serializable +data class TaskArtifact( + val targetId: String, + val type: String, + val stage: String?, + val sessionId: String?, + val excerpt: String?, +) + +/** A link whose target resolved to an agent run: status + intent so the agent has the run's gist. */ +@Serializable +data class RelatedSession( + val targetId: String, + val type: String, + val status: String, + val intent: String?, + val workflowId: String?, +) + /** A link whose target is neither a task nor a resolvable doc — left for the agent to fetch. */ @Serializable data class RelatedLink( diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskSessionResolver.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskSessionResolver.kt new file mode 100644 index 00000000..945828db --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskSessionResolver.kt @@ -0,0 +1,21 @@ +package com.correx.core.tasks + +import kotlinx.serialization.Serializable + +/** + * Port for resolving a SESSION link target (a `SessionId`) to an inline summary, so the context + * bundle can carry the run's status/intent instead of a bare id. Implemented in a higher layer + * (apps/server projects the session's events); `core:tasks` stays decoupled from the session + * aggregate. Returns null when the session can't be resolved — the link then stays a raw related + * link. + */ +interface TaskSessionResolver { + suspend fun resolve(targetId: String): ResolvedSession? +} + +@Serializable +data class ResolvedSession( + val status: String, + val intent: String?, + val workflowId: String?, +) diff --git a/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskContextAssemblerTest.kt b/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskContextAssemblerTest.kt index 5c3cf5d1..13f623f1 100644 --- a/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskContextAssemblerTest.kt +++ b/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskContextAssemblerTest.kt @@ -125,4 +125,39 @@ class TaskContextAssemblerTest { assertTrue(bundle.relevantKnowledge.isEmpty()) assertEquals("JWT refresh", bundle.title) } + + @Test + fun `linked artifacts and sessions are resolved inline and unresolved ones stay raw`() = runBlocking { + val artifacts = object : TaskArtifactResolver { + override suspend fun resolve(targetId: String): ResolvedArtifact? = + if (targetId == "art-1") ResolvedArtifact("architect", "sess-9", "approach: redis") else null + } + val sessions = object : TaskSessionResolver { + override suspend fun resolve(targetId: String): ResolvedSession? = + if (targetId == "sess-9") ResolvedSession("COMPLETED", "build auth", "role_pipeline") else null + } + val enriched = TaskContextAssembler(service, artifactResolver = artifacts, sessionResolver = sessions) + val task = service.createTask(project, "JWT refresh", "auth") + service.link(task.taskId, "art-1", TaskLinkType.PRODUCED, TaskTargetKind.ARTIFACT) + service.link(task.taskId, "sess-9", TaskLinkType.CONTEXT, TaskTargetKind.SESSION) + service.link(task.taskId, "art-missing", TaskLinkType.RELATES_TO, TaskTargetKind.ARTIFACT) // unresolved → raw + + val bundle = enriched.assemble(task.taskId)!! + + val artifact = bundle.artifacts.single() + assertEquals("art-1", artifact.targetId) + assertEquals("architect", artifact.stage) + assertEquals("sess-9", artifact.sessionId) + assertEquals("PRODUCED", artifact.type) + + val session = bundle.sessions.single() + assertEquals("sess-9", session.targetId) + assertEquals("COMPLETED", session.status) + assertEquals("build auth", session.intent) + assertEquals("CONTEXT", session.type) + + assertEquals(RelatedLink("art-missing", "RELATES_TO"), bundle.relatedLinks.single()) + assertTrue(bundle.render().contains("artifacts:")) + assertTrue(bundle.render().contains("sessions:")) + } } diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskTools.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskTools.kt index fe25c87a..869f0ac6 100644 --- a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskTools.kt +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskTools.kt @@ -1,26 +1,33 @@ package com.correx.infrastructure.tools.task +import com.correx.core.tasks.TaskArtifactResolver import com.correx.core.tasks.TaskContextAssembler import com.correx.core.tasks.TaskDocumentResolver import com.correx.core.tasks.TaskKnowledgeRetriever import com.correx.core.tasks.TaskService +import com.correx.core.tasks.TaskSessionResolver import com.correx.core.tools.contract.Tool /** Builds the task tool set over a single [TaskService] for registration in the tool registry. */ object TaskTools { /** - * [retriever] semantically enriches the `task_context` bundle; [documentResolver] inlines - * linked ADRs/docs. Both optional — null leaves the bundle deterministic. + * [retriever] semantically enriches the `task_context` bundle; [documentResolver], + * [artifactResolver] and [sessionResolver] inline DOC/ARTIFACT/SESSION link targets. All + * optional — null leaves those links raw and the bundle deterministic. */ fun forService( service: TaskService, retriever: TaskKnowledgeRetriever? = null, documentResolver: TaskDocumentResolver? = null, + artifactResolver: TaskArtifactResolver? = null, + sessionResolver: TaskSessionResolver? = null, ): List = listOf( TaskCreateTool(service), TaskUpdateTool(service), TaskDeleteTool(service), - TaskContextTool(TaskContextAssembler(service, retriever, documentResolver)), + TaskContextTool( + TaskContextAssembler(service, retriever, documentResolver, artifactResolver, sessionResolver), + ), ) } From 70b43573169a315331b84e7cebffa07758c91e42 Mon Sep 17 00:00:00 2001 From: kami Date: Tue, 23 Jun 2026 20:15:54 +0000 Subject: [PATCH 077/107] feat(tasks): auto-link the originating agent session When an agent creates or claims a task via the tool, record its session as a CONTEXT/SESSION link so the context bundle's session resolver has real edges to inline (status/intent/workflow). Shared linkOriginSession helper; reducer dedupes so create+claim from one session is a single edge. The REST create path stays unlinked (no agent session there). Co-Authored-By: Claude Opus 4.8 --- .../tools/task/TaskCreateTool.kt | 3 +++ .../tools/task/TaskUpdateTool.kt | 7 ++++++- .../infrastructure/tools/task/ToolParams.kt | 15 +++++++++++++ .../tools/task/TaskToolsTest.kt | 21 ++++++++++++++++++- 4 files changed, 44 insertions(+), 2 deletions(-) diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskCreateTool.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskCreateTool.kt index c98e8d7c..d1f5b90c 100644 --- a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskCreateTool.kt +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskCreateTool.kt @@ -80,6 +80,9 @@ class TaskCreateTool(private val service: TaskService) : Tool, ToolExecutor { acceptanceCriteria = request.listParam("acceptance_criteria"), affectedPaths = request.listParam("affected_paths"), ) + // Provenance: tie the new task to the session that spawned it so its context bundle + // can show the originating run. + service.linkOriginSession(task.taskId, request) return ToolResult.Success( invocationId = request.invocationId, output = "Created ${task.taskId.value}: ${task.state.title}", diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt index 80bdcb9c..7b0607b2 100644 --- a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt @@ -127,7 +127,12 @@ class TaskUpdateTool(private val service: TaskService) : Tool, ToolExecutor { val claimant = request.stringParam("claimant") ?: request.sessionId.value val reason = request.stringParam("reason") when (action) { - "claim" -> service.claim(taskId, claimant) + "claim" -> { + service.claim(taskId, claimant) + // Record the claiming session too, so a task picked up by a different run than + // the one that created it links both into its work graph. + service.linkOriginSession(taskId, request) + } "release" -> service.release(taskId) "block" -> service.block(taskId, reason ?: "blocked") "unblock" -> service.unblock(taskId) diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/ToolParams.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/ToolParams.kt index c8968a9c..5f816711 100644 --- a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/ToolParams.kt +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/ToolParams.kt @@ -1,6 +1,10 @@ package com.correx.infrastructure.tools.task import com.correx.core.events.events.ToolRequest +import com.correx.core.events.types.TaskId +import com.correx.core.events.types.TaskLinkType +import com.correx.core.events.types.TaskTargetKind +import com.correx.core.tasks.TaskService /** A non-blank string parameter, or null when absent/blank/not a string. */ internal fun ToolRequest.stringParam(name: String): String? = @@ -9,3 +13,14 @@ internal fun ToolRequest.stringParam(name: String): String? = /** A string-list parameter (JSON array), coerced element-wise; empty when absent. */ internal fun ToolRequest.listParam(name: String): List = (parameters[name] as? List<*>)?.mapNotNull { it?.toString()?.takeIf(String::isNotBlank) } ?: emptyList() + +/** + * Records the agent's session on the task as a CONTEXT/SESSION link, so the context bundle can + * surface the run(s) that created or worked on it (the session resolver inlines status/intent). + * No-op for a blank session id; duplicate links are deduped by the reducer, so creating and then + * claiming from the same session yields a single edge. + */ +internal suspend fun TaskService.linkOriginSession(taskId: TaskId, request: ToolRequest) { + val session = request.sessionId.value.takeIf { it.isNotBlank() } ?: return + link(taskId, session, TaskLinkType.CONTEXT, TaskTargetKind.SESSION) +} diff --git a/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt b/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt index 888afb86..2bd5b10f 100644 --- a/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt +++ b/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt @@ -6,6 +6,9 @@ import com.correx.core.events.events.ToolRequest import com.correx.core.events.stores.EventStore import com.correx.core.events.types.SessionId import com.correx.core.events.types.StageId +import com.correx.core.events.types.TaskId +import com.correx.core.events.types.TaskLinkType +import com.correx.core.events.types.TaskTargetKind import com.correx.core.events.types.ToolInvocationId import com.correx.core.tasks.TaskContextAssembler import com.correx.core.tasks.TaskService @@ -82,10 +85,26 @@ class TaskToolsTest { assertEquals(TaskStatus.IN_PROGRESS, state.status) assertEquals("claude-opus", state.claimant) assertEquals("JWT refresh flow", state.title) - assertEquals("adr-7", state.links.single().targetId) + assertTrue(state.links.any { it.targetId == "adr-7" && it.type == TaskLinkType.IMPLEMENTS }) assertEquals("starting", state.notes.single().body) } + @Test + fun `task_create and claim auto-link the agent session as CONTEXT`() = runBlocking { + val id = createTask() // session s-1 + val taskId = TaskId(id) + + // Created from s-1 → one CONTEXT/SESSION edge. + val afterCreate = service.getTask(taskId)!!.state.links.single() + assertEquals("s-1", afterCreate.targetId) + assertEquals(TaskTargetKind.SESSION, afterCreate.targetKind) + assertEquals(TaskLinkType.CONTEXT, afterCreate.type) + + // Claiming from the same session dedupes to the same single edge. + update.execute(request("task_update", mapOf("id" to id, "action" to "claim"))) + assertEquals(1, service.getTask(taskId)!!.state.links.count { it.targetId == "s-1" }) + } + @Test fun `task_update rejects an unknown action`() = runBlocking { val id = createTask() From 1d7fab4ee483d8bdc7a4d529d0e452b09e624fa6 Mon Sep 17 00:00:00 2001 From: kami Date: Tue, 23 Jun 2026 20:32:48 +0000 Subject: [PATCH 078/107] feat(tasks): TUI task board MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a cross-project task board to the TUI (T / palette): a roster fetched from GET /tasks with vim-style nav, refresh, and an enter-to-open detail pane (goal, criteria, paths, links, notes) built from the list payload — no extra fetch. Server: TaskService.listAll() and a project-optional GET /tasks (scoped with ?project=, whole board without; recent-first). Co-Authored-By: Claude Opus 4.8 --- .../correx/apps/server/routes/TaskRoutes.kt | 12 +- .../apps/server/routes/TaskRoutesTest.kt | 9 +- apps/tui-go/internal/app/model.go | 10 + apps/tui-go/internal/app/overlays.go | 3 + apps/tui-go/internal/app/tasks_overlay.go | 401 ++++++++++++++++++ .../tui-go/internal/app/tasks_overlay_test.go | 131 ++++++ apps/tui-go/internal/app/update.go | 15 + .../com/correx/core/tasks/TaskService.kt | 10 + .../com/correx/core/tasks/TaskServiceTest.kt | 8 + 9 files changed, 593 insertions(+), 6 deletions(-) create mode 100644 apps/tui-go/internal/app/tasks_overlay.go create mode 100644 apps/tui-go/internal/app/tasks_overlay_test.go diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt b/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt index 93c3d1ba..80084996 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt @@ -82,8 +82,9 @@ data class NoteRequest(val body: String, val author: String? = null) /** * Full REST surface for tasks. The write path mirrors the [TaskService] / `task_*` tools; both * append to the event log, which stays the single source of truth (nothing is stored separately). - * A task's project is encoded in its id, so item routes need only the `{id}`; the collection - * routes take a `project` query/body field. [GET /tasks/{id}/context] serves the agent bundle. + * A task's project is encoded in its id, so item routes need only the `{id}`. The list route takes + * an optional `project` (cross-project board without it); create requires `project` in the body. + * [GET /tasks/{id}/context] serves the agent bundle. */ fun Route.taskRoutes(module: ServerModule) { val service = TaskService(module.eventStore) @@ -106,14 +107,15 @@ fun Route.taskRoutes(module: ServerModule) { private fun Route.taskCollectionRoutes(service: TaskService) { get { - val project = call.request.queryParameters["project"] - ?: return@get call.respond(HttpStatusCode.BadRequest, "Missing 'project' query parameter") val statusRaw = call.request.queryParameters["status"] val statusFilter = statusRaw?.let { parseEnum(it) ?: return@get call.respond(HttpStatusCode.BadRequest, "Invalid status: $it") } - val tasks = service.list(ProjectId(project)) + // project is optional: scoped to one project when given, else the whole cross-project board. + val project = call.request.queryParameters["project"] + val tasks = (if (project != null) service.list(ProjectId(project)) else service.listAll()) .filter { statusFilter == null || it.state.status == statusFilter } + .sortedByDescending { it.state.updatedAt } // most recently touched first .map { it.toResponse() } call.respond(tasks) } diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/routes/TaskRoutesTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/routes/TaskRoutesTest.kt index 2ae7aeae..a598b99c 100644 --- a/apps/server/src/test/kotlin/com/correx/apps/server/routes/TaskRoutesTest.kt +++ b/apps/server/src/test/kotlin/com/correx/apps/server/routes/TaskRoutesTest.kt @@ -103,6 +103,13 @@ class TaskRoutesTest { assertEquals(1, rows.size) assertEquals("demo-1", (rows[0] as JsonObject)["id"]!!.jsonPrimitive.content) + // No project → cross-project board lists it too. + val all = client.get("/tasks") + assertEquals(HttpStatusCode.OK, all.status) + val allRows = testJson.parseToJsonElement(all.bodyAsText()) as JsonArray + assertEquals(1, allRows.size) + assertEquals("demo-1", (allRows[0] as JsonObject)["id"]!!.jsonPrimitive.content) + val fetched = client.get("/tasks/demo-1") assertEquals(HttpStatusCode.OK, fetched.status) assertEquals("JWT refresh", fetched.json()["title"]!!.jsonPrimitive.content) @@ -199,7 +206,7 @@ class TaskRoutesTest { val client = createClient {} client.createTask() - assertEquals(HttpStatusCode.BadRequest, client.get("/tasks").status) + assertEquals(HttpStatusCode.BadRequest, client.get("/tasks?status=NONSENSE").status) assertEquals(HttpStatusCode.NotFound, client.get("/tasks/nope-1").status) val badLink = client.post("/tasks/demo-1/links") { contentType(ContentType.Application.Json) diff --git a/apps/tui-go/internal/app/model.go b/apps/tui-go/internal/app/model.go index 5e8dc0e2..ad3b42cc 100644 --- a/apps/tui-go/internal/app/model.go +++ b/apps/tui-go/internal/app/model.go @@ -57,6 +57,7 @@ const ( OverlayStats OverlayIdeas OverlaySessions + OverlayTasks OverlayFiles OverlayStatusbar OverlayGrants @@ -317,6 +318,15 @@ type Model struct { sessionListLoading bool sessionListErr string + // task board (OverlayTasks) — all tasks across projects, fetched over HTTP from + // GET /tasks. taskDetail toggles the per-task detail pane (built from the list payload). + taskList []TaskSummary + taskListIndex int + taskListLoading bool + taskListErr string + taskDetail bool + taskDetailScroll int + // command palette paletteFilter string paletteIndex int diff --git a/apps/tui-go/internal/app/overlays.go b/apps/tui-go/internal/app/overlays.go index cbe99ef2..c106acc8 100644 --- a/apps/tui-go/internal/app/overlays.go +++ b/apps/tui-go/internal/app/overlays.go @@ -125,6 +125,8 @@ func (m Model) renderOverlay(base string) string { return m.center(m.ideasModal()) case OverlaySessions: return m.center(m.sessionsModal()) + case OverlayTasks: + return m.center(m.tasksModal()) case OverlayFiles: return m.center(m.filesModal()) case OverlayStatusbar: @@ -846,6 +848,7 @@ func (m Model) helpBody() []string { {"e", "event inspector (/ to filter)"}, {"t / v", "tools / artifacts"}, {"S / R", "session stats / resume past sessions"}, + {"T", "task board (across projects)"}, {"m / g", "swap model / edit config"}, {"G / I", "grants / idea board"}, }) diff --git a/apps/tui-go/internal/app/tasks_overlay.go b/apps/tui-go/internal/app/tasks_overlay.go new file mode 100644 index 00000000..b80d111d --- /dev/null +++ b/apps/tui-go/internal/app/tasks_overlay.go @@ -0,0 +1,401 @@ +package app + +import ( + "encoding/json" + "image/color" + "io" + "net/http" + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +// TaskSummary is one task from GET /tasks. Mirrors the server's TaskResponse +// (apps/server routes/TaskRoutes.kt) — the wire shape. Nullable server fields +// decode to the zero value ("" / nil) here. +type TaskSummary struct { + ID string `json:"id"` + Key string `json:"key"` + Status string `json:"status"` + Title string `json:"title"` + Goal string `json:"goal"` + AcceptanceCriteria []string `json:"acceptanceCriteria"` + AffectedPaths []string `json:"affectedPaths"` + Claimant string `json:"claimant"` + Links []TaskLinkWire `json:"links"` + Notes []TaskNoteWire `json:"notes"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} + +type TaskLinkWire struct { + TargetID string `json:"targetId"` + Type string `json:"type"` + TargetKind string `json:"targetKind"` +} + +type TaskNoteWire struct { + Author string `json:"author"` + Body string `json:"body"` + At string `json:"at"` +} + +// tasksLoadedMsg carries the result of a GET /tasks fetch. Exactly one of tasks / +// err is meaningful; a non-empty err means the fetch failed and the board shows it. +type tasksLoadedMsg struct { + tasks []TaskSummary + err string +} + +// fetchTasks is a tea.Cmd that GETs the cross-project task board over HTTP and +// reports it as a tasksLoadedMsg. base is the http://host:port origin (from the ws +// client); an empty base yields an error message rather than a panic. +func fetchTasks(base string) tea.Cmd { + return func() tea.Msg { + if base == "" { + return tasksLoadedMsg{err: "no server address"} + } + client := &http.Client{Timeout: sessionsHTTPTimeout} + resp, err := client.Get(base + "/tasks") + if err != nil { + return tasksLoadedMsg{err: "fetch failed: " + err.Error()} + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return tasksLoadedMsg{err: "server returned " + resp.Status} + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return tasksLoadedMsg{err: "read failed: " + err.Error()} + } + var out []TaskSummary + if err := json.Unmarshal(body, &out); err != nil { + return tasksLoadedMsg{err: "decode failed: " + err.Error()} + } + return tasksLoadedMsg{tasks: out} + } +} + +// openTasks opens the cross-project task board and kicks off the HTTP fetch. It is +// global (not bound to a session), so it opens from anywhere. +func (m *Model) openTasks() tea.Cmd { + m.overlay = OverlayTasks + m.taskListIndex = 0 + m.taskListErr = "" + m.taskDetail = false + m.taskDetailScroll = 0 + m.taskListLoading = true + return fetchTasks(m.client.HTTPBase()) +} + +// applyTasksLoaded folds a GET /tasks result into the board, clamping the cursor and +// surfacing any fetch error in place of a crash. +func (m *Model) applyTasksLoaded(msg tasksLoadedMsg) { + m.taskListLoading = false + if msg.err != "" { + m.taskListErr = msg.err + return + } + m.taskListErr = "" + m.taskList = msg.tasks + if m.taskListIndex >= len(m.taskList) { + m.taskListIndex = 0 + } +} + +// selectedTask returns the highlighted task, or false when the list is empty / the +// cursor is out of range. +func (m Model) selectedTask() (TaskSummary, bool) { + if m.taskListIndex < 0 || m.taskListIndex >= len(m.taskList) { + return TaskSummary{}, false + } + return m.taskList[m.taskListIndex], true +} + +// handleTasksKey owns every key while the board is open. In list mode: navigate, r +// refreshes, enter opens the detail pane, T/esc closes. In detail mode: scroll, and +// enter/esc returns to the list. +func (m Model) handleTasksKey(k keyMsg) (tea.Model, tea.Cmd) { + if m.taskDetail { + switch { + case k.Type == keyEsc || k.Type == keyEnter: + m.taskDetail = false + case k.Type == keyUp || runeIs(k, "k"): + m.scrollTaskDetail(-1) + case k.Type == keyDown || runeIs(k, "j"): + m.scrollTaskDetail(1) + case k.Type == keyPgUp: + m.scrollTaskDetail(-m.taskDetailBodyH()) + case k.Type == keyPgDown: + m.scrollTaskDetail(m.taskDetailBodyH()) + case runeIs(k, "g"): + m.taskDetailScroll = 0 + case runeIs(k, "G"): + m.taskDetailScroll = m.taskDetailMaxScroll() + } + return m, nil + } + switch { + case k.Type == keyEsc || runeIs(k, "T"): + m.overlay = OverlayNone + case k.Type == keyUp || runeIs(k, "k"): + if m.taskListIndex > 0 { + m.taskListIndex-- + } + case k.Type == keyDown || runeIs(k, "j"): + if m.taskListIndex < len(m.taskList)-1 { + m.taskListIndex++ + } + case runeIs(k, "r"): + return m, m.openTasks() + case k.Type == keyEnter: + if _, ok := m.selectedTask(); ok { + m.taskDetail = true + m.taskDetailScroll = 0 + } + } + return m, nil +} + +// taskDetailBodyH is the number of detail lines visible in the detail pane. +func (m Model) taskDetailBodyH() int { + h := m.height*70/100 - 6 + if h < 4 { + h = 4 + } + return h +} + +// taskDetailMaxScroll keeps the last page of detail anchored to the body bottom. +func (m Model) taskDetailMaxScroll() int { + max := len(m.taskDetailBody(m.modalWidth()-4)) - m.taskDetailBodyH() + if max < 0 { + max = 0 + } + return max +} + +// scrollTaskDetail moves the detail offset by delta lines, clamped to [0, max]. +func (m *Model) scrollTaskDetail(delta int) { + max := m.taskDetailMaxScroll() + off := m.taskDetailScroll + delta + if off > max { + off = max + } + if off < 0 { + off = 0 + } + m.taskDetailScroll = off +} + +func (m Model) tasksModal() string { + if m.taskDetail { + return m.taskDetailModal() + } + t := m.theme + w := m.modalWidth() + + var b strings.Builder + b.WriteString(m.titleLine("tasks")) + if len(m.taskList) > 0 { + b.WriteString(mbg(t, " ("+itoa(m.taskListIndex+1)+"/"+itoa(len(m.taskList))+")", t.P.Faint)) + } + b.WriteString("\n\n") + + if m.taskListErr != "" { + b.WriteString(lipgloss.NewStyle().Foreground(t.P.Bad).Background(t.P.BgPanel).Render(" ▲ "+truncate(m.taskListErr, w-8)) + "\n") + b.WriteString("\n" + modalHints(t, [][2]string{{"T/esc", "close"}})) + return t.Overlay.Width(w).Render(b.String()) + } + if m.taskListLoading && len(m.taskList) == 0 { + b.WriteString(mbg(t, " loading…", t.P.Faint) + "\n") + b.WriteString("\n" + modalHints(t, [][2]string{{"T/esc", "close"}})) + return t.Overlay.Width(w).Render(b.String()) + } + if len(m.taskList) == 0 { + b.WriteString(mbg(t, " no tasks", t.P.Faint) + "\n") + b.WriteString("\n" + modalHints(t, [][2]string{{"r", "refresh"}, {"T/esc", "close"}})) + return t.Overlay.Width(w).Render(b.String()) + } + + // Windowed roster, keeping the selected row in view. + bodyH := m.height*70/100 - 6 + if bodyH < 4 { + bodyH = 4 + } + off := 0 + if len(m.taskList) > bodyH { + off = m.taskListIndex - bodyH/2 + if off < 0 { + off = 0 + } + if off > len(m.taskList)-bodyH { + off = len(m.taskList) - bodyH + } + } + end := off + bodyH + if end > len(m.taskList) { + end = len(m.taskList) + } + for i := off; i < end; i++ { + b.WriteString(m.taskListRow(i) + "\n") + } + + b.WriteString("\n" + modalHints(t, [][2]string{ + {"↑↓", "select"}, {"enter", "detail"}, {"r", "refresh"}, {"T/esc", "close"}, + })) + return t.Overlay.Width(w).Render(b.String()) +} + +// taskListRow renders one board line: marker, id, status, title, and claimant. +func (m Model) taskListRow(i int) string { + t := m.theme + tk := m.taskList[i] + + marker := mbg(t, " ", t.P.BgPanel) + idFg := t.P.Fg + if i == m.taskListIndex { + marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▸ ") + idFg = t.P.FgStrong + } + + idCell := lipgloss.NewStyle().Foreground(idFg).Background(t.P.BgPanel).Render(padRaw(tk.ID, 12)) + statusCell := lipgloss.NewStyle().Foreground(taskStatusColor(t, tk.Status)).Background(t.P.BgPanel).Bold(true). + Render(padRaw(tk.Status, 12)) + + claim := "" + if tk.Claimant != "" { + claim = " @" + shortSessionID(tk.Claimant) + } + titleW := m.modalWidth() - 6 - 12 - 1 - 12 - 1 - len([]rune(claim)) + if titleW < 8 { + titleW = 8 + } + titleCell := mbg(t, padRaw(truncate(tk.Title, titleW), titleW), t.P.Fg) + claimCell := mbg(t, claim, t.P.Faint) + + return marker + idCell + " " + statusCell + " " + titleCell + claimCell +} + +func (m Model) taskDetailModal() string { + t := m.theme + w := m.modalWidth() + tk, _ := m.selectedTask() + + body := m.taskDetailBody(w - 4) + bodyH := m.taskDetailBodyH() + off := m.taskDetailScroll + if mx := m.taskDetailMaxScroll(); off > mx { + off = mx + } + if off < 0 { + off = 0 + } + + var b strings.Builder + b.WriteString(m.titleLine("task " + tk.ID)) + b.WriteString(lipgloss.NewStyle().Foreground(taskStatusColor(t, tk.Status)).Background(t.P.BgPanel).Bold(true). + Render(" " + tk.Status)) + if len(body) > bodyH { + b.WriteString(mbg(t, " ("+itoa(off+1)+"-"+itoa(min(off+bodyH, len(body)))+"/"+itoa(len(body))+")", t.P.Faint)) + } + b.WriteString("\n\n") + + end := off + bodyH + if end > len(body) { + end = len(body) + } + for i := off; i < end; i++ { + b.WriteString(body[i] + "\n") + } + b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "scroll"}, {"g/G", "ends"}, {"enter/esc", "back"}})) + return t.Overlay.Width(w).Render(b.String()) +} + +// taskDetailBody renders the selected task as styled, wrapped lines: goal, acceptance +// criteria, affected paths, links, and notes. Built entirely from the list payload, so +// it needs no extra fetch. +func (m Model) taskDetailBody(width int) []string { + t := m.theme + tk, ok := m.selectedTask() + if !ok { + return []string{mbg(t, " (no task selected)", t.P.Faint)} + } + if width < 8 { + width = 8 + } + var out []string + section := func(label string) { + out = append(out, lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Bold(true).Render(label)) + } + put := func(s string, fg color.Color) { + for _, ln := range wrapLines([]string{s}, width) { + out = append(out, mbg(t, ln, fg)) + } + } + + put(tk.Title, t.P.FgStrong) + if tk.Claimant != "" { + out = append(out, mbg(t, "claimant: "+tk.Claimant, t.P.Faint)) + } + out = append(out, "") + + if tk.Goal != "" { + section("goal") + put(tk.Goal, t.P.Fg) + out = append(out, "") + } + if len(tk.AcceptanceCriteria) > 0 { + section("acceptance criteria") + for _, c := range tk.AcceptanceCriteria { + put("- "+c, t.P.Fg) + } + out = append(out, "") + } + if len(tk.AffectedPaths) > 0 { + section("affected paths") + for _, p := range tk.AffectedPaths { + out = append(out, mbg(t, clip(" "+p, width), t.P.Dim)) + } + out = append(out, "") + } + if len(tk.Links) > 0 { + section("links") + for _, l := range tk.Links { + out = append(out, mbg(t, clip(" "+l.TargetID+" ("+l.Type+" → "+l.TargetKind+")", width), t.P.Fg)) + } + out = append(out, "") + } + if len(tk.Notes) > 0 { + section("notes") + for _, n := range tk.Notes { + put("["+n.Author+"] "+n.Body, t.P.Fg) + } + out = append(out, "") + } + + // Drop the trailing blank so the body never has a dangling empty line. + for len(out) > 0 && out[len(out)-1] == "" { + out = out[:len(out)-1] + } + return out +} + +// taskStatusColor maps a task status to a palette color for the board / detail header. +func taskStatusColor(t Theme, status string) color.Color { + switch status { + case "DONE": + return t.P.OK + case "IN_PROGRESS": + return t.P.Accent + case "IN_REVIEW": + return t.P.Accent2 + case "BLOCKED": + return t.P.Bad + case "CANCELLED": + return t.P.Dim + default: // TODO + return t.P.Faint + } +} diff --git a/apps/tui-go/internal/app/tasks_overlay_test.go b/apps/tui-go/internal/app/tasks_overlay_test.go new file mode 100644 index 00000000..b656ec27 --- /dev/null +++ b/apps/tui-go/internal/app/tasks_overlay_test.go @@ -0,0 +1,131 @@ +package app + +import ( + "strings" + "testing" +) + +func tasksModel() Model { + m := NewModel(nil) + m.width, m.height = 120, 40 + m.theme = NewTheme(SoftBlue) + return m +} + +// sampleTasks is a deterministic two-row board used across the tests. +func sampleTasks() []TaskSummary { + return []TaskSummary{ + { + ID: "auth-1", Key: "auth-1", Status: "IN_PROGRESS", Title: "JWT refresh", + Goal: "users stay authenticated", AcceptanceCriteria: []string{"rotates"}, + Claimant: "aaaaaaaa-1111", + Links: []TaskLinkWire{{TargetID: "adr-7", Type: "IMPLEMENTS", TargetKind: "DOC"}}, + Notes: []TaskNoteWire{{Author: "AGENT", Body: "kickoff"}}, + }, + {ID: "billing-3", Key: "billing-3", Status: "DONE", Title: "Invoice export"}, + } +} + +func TestTasksOverlayToggle(t *testing.T) { + m := tasksModel() + // `T` opens the board from the idle list. + updated, _ := m.handleNormalKey(keyMsg{Type: keyRunes, Runes: []rune("T")}) + m = updated.(Model) + if m.overlay != OverlayTasks { + t.Fatalf("expected OverlayTasks after T, got %d", m.overlay) + } + if !m.taskListLoading { + t.Fatalf("expected loading state right after open") + } + // `esc` closes it (routed through the overlay key handler). + updated, _ = m.handleOverlayKey(keyMsg{Type: keyEsc}) + m = updated.(Model) + if m.overlay != OverlayNone { + t.Fatalf("expected overlay closed after esc, got %d", m.overlay) + } +} + +func TestTasksLoadedPopulatesAndRenders(t *testing.T) { + m := tasksModel() + m.overlay = OverlayTasks + m.taskListLoading = true + m.applyTasksLoaded(tasksLoadedMsg{tasks: sampleTasks()}) + if m.taskListLoading { + t.Fatalf("expected loading cleared after load") + } + if len(m.taskList) != 2 { + t.Fatalf("expected 2 tasks, got %d", len(m.taskList)) + } + out := m.tasksModal() + if !strings.Contains(out, "auth-1") || !strings.Contains(out, "billing-3") { + t.Fatalf("modal missing task ids:\n%s", out) + } + if !strings.Contains(out, "IN_PROGRESS") || !strings.Contains(out, "DONE") { + t.Fatalf("modal missing statuses:\n%s", out) + } +} + +func TestTasksEnterOpensDetailWithBundleFields(t *testing.T) { + m := tasksModel() + m.overlay = OverlayTasks + m.taskList = sampleTasks() + m.taskListIndex = 0 + + updated, _ := m.handleTasksKey(keyMsg{Type: keyEnter}) + m = updated.(Model) + if !m.taskDetail { + t.Fatalf("expected detail pane open after enter") + } + out := m.tasksModal() + for _, want := range []string{"users stay authenticated", "rotates", "adr-7", "kickoff"} { + if !strings.Contains(out, want) { + t.Fatalf("detail missing %q:\n%s", want, out) + } + } + // esc returns to the list, not all the way out. + updated, _ = m.handleTasksKey(keyMsg{Type: keyEsc}) + m = updated.(Model) + if m.taskDetail { + t.Fatalf("expected detail closed after esc") + } + if m.overlay != OverlayTasks { + t.Fatalf("expected to stay on the board after esc from detail, got %d", m.overlay) + } +} + +func TestTasksNavigationBoundsChecked(t *testing.T) { + m := tasksModel() + m.overlay = OverlayTasks + m.taskList = sampleTasks() + // Up at the top is a no-op (clamped at 0). + updated, _ := m.handleTasksKey(keyMsg{Type: keyUp}) + m = updated.(Model) + if m.taskListIndex != 0 { + t.Fatalf("expected index clamped to 0 at top, got %d", m.taskListIndex) + } + // Down then clamped at the last row. + updated, _ = m.handleTasksKey(keyMsg{Type: keyDown}) + m = updated.(Model) + updated, _ = m.handleTasksKey(keyMsg{Type: keyDown}) + m = updated.(Model) + if m.taskListIndex != 1 { + t.Fatalf("expected index clamped to 1 at bottom, got %d", m.taskListIndex) + } +} + +func TestTasksFetchErrorShownNoPanic(t *testing.T) { + m := tasksModel() + m.overlay = OverlayTasks + m.taskListLoading = true + m.applyTasksLoaded(tasksLoadedMsg{err: "server returned 503 Service Unavailable"}) + if m.taskListLoading { + t.Fatalf("expected loading cleared on error") + } + if m.taskListErr == "" { + t.Fatalf("expected error recorded") + } + out := m.tasksModal() + if !strings.Contains(out, "503") { + t.Fatalf("modal should surface the fetch error:\n%s", out) + } +} diff --git a/apps/tui-go/internal/app/update.go b/apps/tui-go/internal/app/update.go index 83e75465..8b31b9e4 100644 --- a/apps/tui-go/internal/app/update.go +++ b/apps/tui-go/internal/app/update.go @@ -116,6 +116,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil + case tasksLoadedMsg: + m.applyTasksLoaded(msg) + return m, nil + case tea.KeyPressMsg: next, cmd := m.handleKey(toKeyMsg(msg)) // A keypress may have entered a typing/active state (insert mode, steering, …); @@ -300,6 +304,9 @@ func (m Model) handleNormalKey(k keyMsg) (tea.Model, tea.Cmd) { // Resume browser: recent sessions over HTTP, reachable even after a server // restart + TUI reopen (the live stream only carries running sessions). return m, m.openSessions() + case "T": + // Task board: all tasks across projects over HTTP (GET /tasks). + return m, m.openTasks() case "S": m.openStats() case "G": @@ -641,6 +648,11 @@ func (m Model) handleOverlayKey(k keyMsg) (tea.Model, tea.Cmd) { if m.overlay == OverlaySessions { return m.handleSessionsKey(k) } + // Tasks overlay owns all its keys: r refresh emits a cmd and enter toggles a detail + // pane, so it can't share the generic no-cmd esc path below. + if m.overlay == OverlayTasks { + return m.handleTasksKey(k) + } if k.Type == keyEsc { // In the event inspector, esc first stops an in-progress filter edit (keeping the // query) rather than closing the overlay. @@ -1095,6 +1107,7 @@ func paletteCommands() []paletteCmd { {"artifacts", "v", "view artifacts", "browse this session's artifacts"}, {"stats", "S", "session stats", "metrics for the selected session"}, {"sessions", "R", "resume session", "browse / resume recent sessions"}, + {"tasks", "T", "task board", "browse tasks across projects"}, {"config", "g", "edit config", "view / change correx settings"}, {"grants", "G", "grants", "view / revoke standing grants"}, {"statusbar", "", "status bar", "show / hide status-bar segments"}, @@ -1141,6 +1154,8 @@ func (m Model) execPalette(id string) (tea.Model, tea.Cmd) { m.openStats() case "sessions": return m, m.openSessions() + case "tasks": + return m, m.openTasks() case "config": m.openConfig() case "grants": diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskService.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskService.kt index 04c9566e..7083c07a 100644 --- a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskService.kt +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskService.kt @@ -55,6 +55,16 @@ class TaskService( fun list(projectId: ProjectId): List = board(projectId).filterValues { !it.deleted }.map { (id, state) -> Task(id, state) } + /** + * Every task across every project, for a cross-project board. Enumerates the per-project task + * streams ([TaskStreams.PREFIX]) and rebuilds each — there is no global task stream, so this is + * the union of the per-project boards. + */ + fun listAll(): List = + eventStore.allSessionIds() + .filter { it.value.startsWith(TaskStreams.PREFIX) } + .flatMap { stream -> list(ProjectId(stream.value.removePrefix(TaskStreams.PREFIX))) } + fun getTask(taskId: TaskId): Task? { val state = board(projectOf(taskId))[taskId] ?: return null return if (state.deleted) null else Task(taskId, state) diff --git a/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskServiceTest.kt b/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskServiceTest.kt index 83bc0213..469692ef 100644 --- a/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskServiceTest.kt +++ b/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskServiceTest.kt @@ -72,6 +72,14 @@ class TaskServiceTest { assertNull(service.claim(TaskId("auth-999"), "claude-opus")) } + @Test + fun `listAll spans every project's stream`() = runBlocking { + service.createTask(project, "a", "g") + service.createTask(ProjectId("billing"), "b", "g") + + assertEquals(setOf("auth-1", "billing-1"), service.listAll().map { it.taskId.value }.toSet()) + } + @Test fun `keys keep incrementing even after deletion`() = runBlocking { val first = service.createTask(project, "one", "g").taskId From 99f781687f264ae91f8e6940695873346516b55b Mon Sep 17 00:00:00 2001 From: kami Date: Tue, 23 Jun 2026 22:08:11 +0000 Subject: [PATCH 079/107] feat(tasks): task search across REST, tool, and TUI Ranked exact/substring search: TaskSearch (all terms AND, ranked title > key > goal > criteria > notes) over one project or the whole board via TaskService.search. Surfaced as GET /tasks?q=, a read-only task_search tool for agents, and a `/` filter in the TUI board. Co-Authored-By: Claude Opus 4.8 --- .../correx/apps/server/routes/TaskRoutes.kt | 18 ++- .../apps/server/routes/TaskRoutesTest.kt | 25 +++- apps/tui-go/internal/app/model.go | 2 + apps/tui-go/internal/app/tasks_overlay.go | 108 ++++++++++++++---- .../tui-go/internal/app/tasks_overlay_test.go | 35 ++++++ .../com/correx/core/tasks/TaskSearch.kt | 46 ++++++++ .../com/correx/core/tasks/TaskService.kt | 4 + .../com/correx/core/tasks/TaskSearchTest.kt | 59 ++++++++++ .../tools/task/TaskSearchTool.kt | 70 ++++++++++++ .../infrastructure/tools/task/TaskTools.kt | 1 + .../tools/task/TaskToolsTest.kt | 17 +++ 11 files changed, 357 insertions(+), 28 deletions(-) create mode 100644 core/tasks/src/main/kotlin/com/correx/core/tasks/TaskSearch.kt create mode 100644 core/tasks/src/test/kotlin/com/correx/core/tasks/TaskSearchTest.kt create mode 100644 infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskSearchTool.kt diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt b/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt index 80084996..88e7a656 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt @@ -8,6 +8,7 @@ import com.correx.core.events.types.TaskNoteAuthor import com.correx.core.events.types.TaskTargetKind import com.correx.core.tasks.Task import com.correx.core.tasks.TaskContextAssembler +import com.correx.core.tasks.TaskSearch import com.correx.core.tasks.TaskService import com.correx.core.tasks.TaskStatus import com.correx.core.tasks.TaskTargetKinds @@ -83,8 +84,8 @@ data class NoteRequest(val body: String, val author: String? = null) * Full REST surface for tasks. The write path mirrors the [TaskService] / `task_*` tools; both * append to the event log, which stays the single source of truth (nothing is stored separately). * A task's project is encoded in its id, so item routes need only the `{id}`. The list route takes - * an optional `project` (cross-project board without it); create requires `project` in the body. - * [GET /tasks/{id}/context] serves the agent bundle. + * optional `project` (cross-project board without it), `status`, and `q` (ranked text search); + * create requires `project` in the body. [GET /tasks/{id}/context] serves the agent bundle. */ fun Route.taskRoutes(module: ServerModule) { val service = TaskService(module.eventStore) @@ -113,11 +114,16 @@ private fun Route.taskCollectionRoutes(service: TaskService) { } // project is optional: scoped to one project when given, else the whole cross-project board. val project = call.request.queryParameters["project"] - val tasks = (if (project != null) service.list(ProjectId(project)) else service.listAll()) + val base = (if (project != null) service.list(ProjectId(project)) else service.listAll()) .filter { statusFilter == null || it.state.status == statusFilter } - .sortedByDescending { it.state.updatedAt } // most recently touched first - .map { it.toResponse() } - call.respond(tasks) + // q ranks by relevance; without it the board is most-recently-touched first. + val q = call.request.queryParameters["q"] + val ordered = if (q.isNullOrBlank()) { + base.sortedByDescending { it.state.updatedAt } + } else { + TaskSearch.search(base, q) + } + call.respond(ordered.map { it.toResponse() }) } post { val body = call.receive() diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/routes/TaskRoutesTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/routes/TaskRoutesTest.kt index a598b99c..8a8823ae 100644 --- a/apps/server/src/test/kotlin/com/correx/apps/server/routes/TaskRoutesTest.kt +++ b/apps/server/src/test/kotlin/com/correx/apps/server/routes/TaskRoutesTest.kt @@ -130,8 +130,10 @@ class TaskRoutesTest { assertEquals("IN_PROGRESS", claimed.json()["status"]!!.jsonPrimitive.content) assertEquals("claude-opus", claimed.json()["claimant"]!!.jsonPrimitive.content) - assertEquals("IN_REVIEW", client.post("/tasks/demo-1/submit-for-review").json()["status"]!!.jsonPrimitive.content) - assertEquals("DONE", client.post("/tasks/demo-1/complete").json()["status"]!!.jsonPrimitive.content) + val reviewed = client.post("/tasks/demo-1/submit-for-review").json() + assertEquals("IN_REVIEW", reviewed["status"]!!.jsonPrimitive.content) + val completed = client.post("/tasks/demo-1/complete").json() + assertEquals("DONE", completed["status"]!!.jsonPrimitive.content) } } @@ -185,6 +187,25 @@ class TaskRoutesTest { } } + @Test + fun `GET tasks with q returns only ranked search hits`(@TempDir tempDir: Path) { + testApplication { + application { configureServer(buildModule(tempDir)) } + val client = createClient {} + client.createTask() // demo-1: "JWT refresh" / "users stay authed" + client.post("/tasks") { + contentType(ContentType.Application.Json) + setBody("""{"project":"demo","title":"Rotate keys","goal":"rotate signing keys"}""") + } + + val res = client.get("/tasks?q=jwt") + assertEquals(HttpStatusCode.OK, res.status) + val rows = testJson.parseToJsonElement(res.bodyAsText()) as JsonArray + assertEquals(1, rows.size) + assertEquals("demo-1", (rows[0] as JsonObject)["id"]!!.jsonPrimitive.content) + } + } + @Test fun `context bundle is served for a known task`(@TempDir tempDir: Path) { testApplication { diff --git a/apps/tui-go/internal/app/model.go b/apps/tui-go/internal/app/model.go index ad3b42cc..17dc1aa6 100644 --- a/apps/tui-go/internal/app/model.go +++ b/apps/tui-go/internal/app/model.go @@ -326,6 +326,8 @@ type Model struct { taskListErr string taskDetail bool taskDetailScroll int + taskFilter string // `/` substring narrow over id/title/goal + taskFilterTyping bool // command palette paletteFilter string diff --git a/apps/tui-go/internal/app/tasks_overlay.go b/apps/tui-go/internal/app/tasks_overlay.go index b80d111d..9c15952e 100644 --- a/apps/tui-go/internal/app/tasks_overlay.go +++ b/apps/tui-go/internal/app/tasks_overlay.go @@ -85,10 +85,28 @@ func (m *Model) openTasks() tea.Cmd { m.taskListErr = "" m.taskDetail = false m.taskDetailScroll = 0 + m.taskFilter = "" + m.taskFilterTyping = false m.taskListLoading = true return fetchTasks(m.client.HTTPBase()) } +// filteredTasks narrows the board by the `/` query — a case-insensitive substring over +// id/title/goal. An empty query returns the whole list. +func (m Model) filteredTasks() []TaskSummary { + if strings.TrimSpace(m.taskFilter) == "" { + return m.taskList + } + q := strings.ToLower(m.taskFilter) + var out []TaskSummary + for _, tk := range m.taskList { + if strings.Contains(strings.ToLower(tk.ID+" "+tk.Title+" "+tk.Goal), q) { + out = append(out, tk) + } + } + return out +} + // applyTasksLoaded folds a GET /tasks result into the board, clamping the cursor and // surfacing any fetch error in place of a crash. func (m *Model) applyTasksLoaded(msg tasksLoadedMsg) { @@ -107,10 +125,11 @@ func (m *Model) applyTasksLoaded(msg tasksLoadedMsg) { // selectedTask returns the highlighted task, or false when the list is empty / the // cursor is out of range. func (m Model) selectedTask() (TaskSummary, bool) { - if m.taskListIndex < 0 || m.taskListIndex >= len(m.taskList) { + f := m.filteredTasks() + if m.taskListIndex < 0 || m.taskListIndex >= len(f) { return TaskSummary{}, false } - return m.taskList[m.taskListIndex], true + return f[m.taskListIndex], true } // handleTasksKey owns every key while the board is open. In list mode: navigate, r @@ -136,15 +155,36 @@ func (m Model) handleTasksKey(k keyMsg) (tea.Model, tea.Cmd) { } return m, nil } + // While editing the `/` filter, keys feed the query (esc/enter stop editing, keeping it). + if m.taskFilterTyping { + switch { + case k.Type == keyEnter || k.Type == keyEsc: + m.taskFilterTyping = false + case k.Type == keyBackspace: + if n := len(m.taskFilter); n > 0 { + m.taskFilter = m.taskFilter[:n-1] + m.taskListIndex = 0 + } + case k.Type == keyRunes || k.Type == keySpace: + m.taskFilter += string(k.Runes) + m.taskListIndex = 0 + } + return m, nil + } switch { case k.Type == keyEsc || runeIs(k, "T"): m.overlay = OverlayNone + case runeIs(k, "/"): + m.taskFilterTyping = true + case runeIs(k, "c"): + m.taskFilter = "" + m.taskListIndex = 0 case k.Type == keyUp || runeIs(k, "k"): if m.taskListIndex > 0 { m.taskListIndex-- } case k.Type == keyDown || runeIs(k, "j"): - if m.taskListIndex < len(m.taskList)-1 { + if m.taskListIndex < len(m.filteredTasks())-1 { m.taskListIndex++ } case runeIs(k, "r"): @@ -195,11 +235,12 @@ func (m Model) tasksModal() string { } t := m.theme w := m.modalWidth() + tasks := m.filteredTasks() var b strings.Builder b.WriteString(m.titleLine("tasks")) - if len(m.taskList) > 0 { - b.WriteString(mbg(t, " ("+itoa(m.taskListIndex+1)+"/"+itoa(len(m.taskList))+")", t.P.Faint)) + if len(tasks) > 0 { + b.WriteString(mbg(t, " ("+itoa(m.taskListIndex+1)+"/"+itoa(len(tasks))+")", t.P.Faint)) } b.WriteString("\n\n") @@ -213,9 +254,19 @@ func (m Model) tasksModal() string { b.WriteString("\n" + modalHints(t, [][2]string{{"T/esc", "close"}})) return t.Overlay.Width(w).Render(b.String()) } - if len(m.taskList) == 0 { - b.WriteString(mbg(t, " no tasks", t.P.Faint) + "\n") - b.WriteString("\n" + modalHints(t, [][2]string{{"r", "refresh"}, {"T/esc", "close"}})) + + // Filter prompt: shown while editing the `/` query or whenever one is set. + if m.taskFilterTyping || m.taskFilter != "" { + b.WriteString(m.taskFilterLine() + "\n\n") + } + + if len(tasks) == 0 { + msg := " no tasks" + if m.taskFilter != "" { + msg = " no tasks match \"" + m.taskFilter + "\"" + } + b.WriteString(mbg(t, msg, t.P.Faint) + "\n") + b.WriteString("\n" + modalHints(t, m.taskListHints())) return t.Overlay.Width(w).Render(b.String()) } @@ -225,33 +276,50 @@ func (m Model) tasksModal() string { bodyH = 4 } off := 0 - if len(m.taskList) > bodyH { + if len(tasks) > bodyH { off = m.taskListIndex - bodyH/2 if off < 0 { off = 0 } - if off > len(m.taskList)-bodyH { - off = len(m.taskList) - bodyH + if off > len(tasks)-bodyH { + off = len(tasks) - bodyH } } end := off + bodyH - if end > len(m.taskList) { - end = len(m.taskList) + if end > len(tasks) { + end = len(tasks) } for i := off; i < end; i++ { - b.WriteString(m.taskListRow(i) + "\n") + b.WriteString(m.taskListRow(tasks, i) + "\n") } - b.WriteString("\n" + modalHints(t, [][2]string{ - {"↑↓", "select"}, {"enter", "detail"}, {"r", "refresh"}, {"T/esc", "close"}, - })) + b.WriteString("\n" + modalHints(t, m.taskListHints())) return t.Overlay.Width(w).Render(b.String()) } -// taskListRow renders one board line: marker, id, status, title, and claimant. -func (m Model) taskListRow(i int) string { +// taskListHints is the list-mode footer (shared by the populated and empty-filter views). +func (m Model) taskListHints() [][2]string { + return [][2]string{{"↑↓", "select"}, {"enter", "detail"}, {"/", "filter"}, {"r", "refresh"}, {"T/esc", "close"}} +} + +// taskFilterLine renders the `/` filter prompt over the current query, with a caret while editing. +func (m Model) taskFilterLine() string { t := m.theme - tk := m.taskList[i] + prompt := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("/ ") + caret := mbg(t, "", t.P.BgPanel) + if m.taskFilterTyping && m.caretVisible() { + caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▏") + } + if m.taskFilter == "" { + return prompt + caret + mbg(t, "type to filter tasks…", t.P.Faint) + } + return prompt + mbg(t, m.taskFilter, t.P.FgStrong) + caret +} + +// taskListRow renders one board line: marker, id, status, title, and claimant. +func (m Model) taskListRow(tasks []TaskSummary, i int) string { + t := m.theme + tk := tasks[i] marker := mbg(t, " ", t.P.BgPanel) idFg := t.P.Fg diff --git a/apps/tui-go/internal/app/tasks_overlay_test.go b/apps/tui-go/internal/app/tasks_overlay_test.go index b656ec27..1fb93554 100644 --- a/apps/tui-go/internal/app/tasks_overlay_test.go +++ b/apps/tui-go/internal/app/tasks_overlay_test.go @@ -113,6 +113,41 @@ func TestTasksNavigationBoundsChecked(t *testing.T) { } } +func TestTasksFilterNarrowsList(t *testing.T) { + m := tasksModel() + m.overlay = OverlayTasks + m.taskList = sampleTasks() + + // `/` enters filter typing; a query narrows the board to matching rows. + updated, _ := m.handleTasksKey(keyMsg{Type: keyRunes, Runes: []rune("/")}) + m = updated.(Model) + if !m.taskFilterTyping { + t.Fatalf("expected filter typing after /") + } + for _, r := range "invoice" { + updated, _ = m.handleTasksKey(keyMsg{Type: keyRunes, Runes: []rune{r}}) + m = updated.(Model) + } + if got := m.filteredTasks(); len(got) != 1 || got[0].ID != "billing-3" { + t.Fatalf("expected only billing-3 to match 'invoice', got %+v", got) + } + // enter stops editing but keeps the query; the selected task is the filtered one. + updated, _ = m.handleTasksKey(keyMsg{Type: keyEnter}) + m = updated.(Model) + if m.taskFilterTyping { + t.Fatalf("expected typing to stop on enter") + } + if sel, ok := m.selectedTask(); !ok || sel.ID != "billing-3" { + t.Fatalf("expected selected task billing-3 under filter, got %+v ok=%v", sel, ok) + } + // `c` clears the filter back to the full board. + updated, _ = m.handleTasksKey(keyMsg{Type: keyRunes, Runes: []rune("c")}) + m = updated.(Model) + if len(m.filteredTasks()) != 2 { + t.Fatalf("expected full board after clear, got %d", len(m.filteredTasks())) + } +} + func TestTasksFetchErrorShownNoPanic(t *testing.T) { m := tasksModel() m.overlay = OverlayTasks diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskSearch.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskSearch.kt new file mode 100644 index 00000000..73ceb47c --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskSearch.kt @@ -0,0 +1,46 @@ +package com.correx.core.tasks + +/** + * Exact/substring task search. A task matches when every whitespace-separated term in the query + * appears (case-insensitive) in some searchable field; results are ranked by the strongest field a + * term hits (title > key > goal > acceptance criteria > notes), ties broken by id for stability. + * Pure and store-free so it can be reused over [TaskService.list]/[TaskService.listAll] and unit + * tested directly. + */ +object TaskSearch { + + fun search(tasks: List, query: String): List { + val terms = query.trim().lowercase().split(WHITESPACE).filter { it.isNotBlank() } + if (terms.isEmpty()) return tasks + return tasks + .mapNotNull { task -> score(task, terms)?.let { task to it } } + .sortedWith(compareByDescending> { it.second }.thenBy { it.first.taskId.value }) + .map { it.first } + } + + /** Sum of the best per-term field weight, or null when any term matches no field. */ + private fun score(task: Task, terms: List): Int? { + val s = task.state + val fields = listOf( + (s.title ?: "") to TITLE_WEIGHT, + (s.key ?: "") to KEY_WEIGHT, + (s.goal ?: "") to GOAL_WEIGHT, + s.acceptanceCriteria.joinToString(" ") to CRITERIA_WEIGHT, + s.notes.joinToString(" ") { it.body } to NOTES_WEIGHT, + ).map { (text, weight) -> text.lowercase() to weight } + + var total = 0 + for (term in terms) { + val best = fields.filter { it.first.contains(term) }.maxOfOrNull { it.second } ?: return null + total += best + } + return total + } + + private val WHITESPACE = Regex("\\s+") + private const val TITLE_WEIGHT = 5 + private const val KEY_WEIGHT = 4 + private const val GOAL_WEIGHT = 3 + private const val CRITERIA_WEIGHT = 2 + private const val NOTES_WEIGHT = 1 +} diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskService.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskService.kt index 7083c07a..4aee0d68 100644 --- a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskService.kt +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskService.kt @@ -70,6 +70,10 @@ class TaskService( return if (state.deleted) null else Task(taskId, state) } + /** Ranked text search over one project (when given) or the whole cross-project board. */ + fun search(query: String, projectId: ProjectId? = null): List = + TaskSearch.search(projectId?.let { list(it) } ?: listAll(), query) + suspend fun createTask( projectId: ProjectId, title: String, diff --git a/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskSearchTest.kt b/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskSearchTest.kt new file mode 100644 index 00000000..e149092a --- /dev/null +++ b/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskSearchTest.kt @@ -0,0 +1,59 @@ +package com.correx.core.tasks + +import com.correx.core.events.types.TaskId +import com.correx.core.events.types.TaskNoteAuthor +import kotlinx.datetime.Instant +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class TaskSearchTest { + + private fun task( + id: String, + title: String?, + goal: String? = null, + criteria: List = emptyList(), + notes: List = emptyList(), + ) = Task( + TaskId(id), + TaskState( + key = id, + title = title, + goal = goal, + acceptanceCriteria = criteria, + notes = notes.map { TaskNote(TaskNoteAuthor.AGENT, it, Instant.parse("2026-01-01T00:00:00Z")) }, + ), + ) + + @Test + fun `requires all terms and ranks title hits above body hits`() { + val titleHit = task("auth-1", title = "JWT refresh flow", goal = "stay authed") + val goalHit = task("auth-2", title = "Token rotation", goal = "rotate the jwt refresh token") + val noMatch = task("auth-3", title = "Logging", goal = "structured logs") + + val results = TaskSearch.search(listOf(goalHit, noMatch, titleHit), "jwt refresh") + + assertEquals(listOf("auth-1", "auth-2"), results.map { it.taskId.value }) + } + + @Test + fun `matches across acceptance criteria and notes`() { + val viaNotes = task("auth-1", title = "x", notes = listOf("the migration failed on rollback")) + val viaCriteria = task("auth-2", title = "y", criteria = listOf("rollback is idempotent")) + + val results = TaskSearch.search(listOf(viaNotes, viaCriteria), "rollback") + + assertEquals(setOf("auth-1", "auth-2"), results.map { it.taskId.value }.toSet()) + } + + @Test + fun `blank query returns the input unchanged`() { + val tasks = listOf(task("auth-1", "a"), task("auth-2", "b")) + assertEquals(tasks, TaskSearch.search(tasks, " ")) + } + + @Test + fun `no match yields empty`() { + assertEquals(emptyList(), TaskSearch.search(listOf(task("auth-1", "nothing here")), "zebra")) + } +} diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskSearchTool.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskSearchTool.kt new file mode 100644 index 00000000..a4b80c39 --- /dev/null +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskSearchTool.kt @@ -0,0 +1,70 @@ +package com.correx.infrastructure.tools.task + +import com.correx.core.approvals.Tier +import com.correx.core.events.events.ToolRequest +import com.correx.core.events.types.ProjectId +import com.correx.core.tasks.TaskService +import com.correx.core.tools.contract.Tool +import com.correx.core.tools.contract.ToolCapability +import com.correx.core.tools.contract.ToolExecutor +import com.correx.core.tools.contract.ToolResult +import com.correx.core.tools.contract.ValidationResult +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.add +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonObject + +private const val DEFAULT_LIMIT = 20 + +/** + * Agent-facing tool: find tasks by text across projects (matches id/title/goal/criteria/notes), + * ranked by relevance. Read-only, so it sits at the lowest tier (like [TaskContextTool]) — agents + * use it to discover task ids before fetching a context bundle. + */ +class TaskSearchTool(private val service: TaskService) : Tool, ToolExecutor { + + override val name: String = "task_search" + override val description: String = + "Search tasks by text (matches id/title/goal/acceptance criteria/notes), ranked by " + + "relevance. Returns 'id [STATUS] title' lines. Use to find a task id, then task_context." + override val parametersSchema: JsonObject = buildJsonObject { + put("type", "object") + putJsonObject("properties") { + putJsonObject("query") { put("type", "string"); put("description", "Free-text query; all terms must match.") } + putJsonObject("project") { + put("type", "string") + put("description", "Limit to a project (e.g. 'auth'). Omit to search every project.") + } + putJsonObject("limit") { put("type", "integer"); put("description", "Max results (default $DEFAULT_LIMIT).") } + } + put("required", buildJsonArray { add(JsonPrimitive("query")) }) + } + override val tier: Tier = Tier.T1 + override val requiredCapabilities: Set = emptySet() + + override fun validateRequest(request: ToolRequest): ValidationResult { + request.stringParam("query") ?: return ValidationResult.Invalid("Missing 'query' (string).") + return ValidationResult.Valid + } + + override suspend fun execute(request: ToolRequest): ToolResult { + val query = request.stringParam("query") + ?: return ToolResult.Failure(request.invocationId, "Missing 'query' (string).", recoverable = false) + val project = request.stringParam("project")?.let { ProjectId(it) } + val limit = (request.parameters["limit"] as? Number)?.toInt()?.takeIf { it > 0 } ?: DEFAULT_LIMIT + val hits = service.search(query, project).take(limit) + val output = if (hits.isEmpty()) { + "no tasks match \"$query\"" + } else { + hits.joinToString("\n") { "${it.taskId.value} [${it.state.status.name}] ${it.state.title.orEmpty()}".trimEnd() } + } + return ToolResult.Success( + invocationId = request.invocationId, + output = output, + metadata = mapOf("query" to query, "count" to hits.size.toString()), + ) + } +} diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskTools.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskTools.kt index 869f0ac6..3c5e61c6 100644 --- a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskTools.kt +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskTools.kt @@ -26,6 +26,7 @@ object TaskTools { TaskCreateTool(service), TaskUpdateTool(service), TaskDeleteTool(service), + TaskSearchTool(service), TaskContextTool( TaskContextAssembler(service, retriever, documentResolver, artifactResolver, sessionResolver), ), diff --git a/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt b/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt index 2bd5b10f..3c9707d3 100644 --- a/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt +++ b/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt @@ -18,6 +18,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.runBlocking 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 @@ -28,6 +29,7 @@ class TaskToolsTest { private val create = TaskCreateTool(service) private val update = TaskUpdateTool(service) private val delete = TaskDeleteTool(service) + private val search = TaskSearchTool(service) private val context = TaskContextTool(TaskContextAssembler(service)) private var counter = 0 @@ -138,6 +140,21 @@ class TaskToolsTest { assertTrue(context.execute(request("task_context", mapOf("id" to "auth-999"))) is ToolResult.Failure) } + @Test + fun `task_search ranks matches across projects and reports misses`() = runBlocking { + create.execute(request("task_create", mapOf("project" to "auth", "title" to "JWT refresh", "goal" to "stay authed"))) + create.execute(request("task_create", mapOf("project" to "auth", "title" to "Rotate keys", "goal" to "rotate signing keys"))) + + val hit = search.execute(request("task_search", mapOf("query" to "jwt"))) + assertTrue(hit is ToolResult.Success) + val out = (hit as ToolResult.Success).output + assertTrue(out.contains("auth-1")) + assertFalse(out.contains("auth-2")) + + val miss = search.execute(request("task_search", mapOf("query" to "zebra"))) + assertTrue((miss as ToolResult.Success).output.contains("no tasks match")) + } + @Test fun `task_delete tombstones the task`() = runBlocking { val id = createTask() From 8d84ccf92f51121cf76aec2e88405b0b184095b4 Mon Sep 17 00:00:00 2001 From: kami Date: Wed, 24 Jun 2026 06:37:34 +0000 Subject: [PATCH 080/107] feat(tasks): git-driven task status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /tasks/sync-git advances task status from commit messages: a mention moves a task to IN_PROGRESS, a closing keyword (fixes/closes/resolves auth-12) walks it the legal path to DONE. Only ever advances (never regresses or touches BLOCKED/CANCELLED), so re-running is idempotent; each advance leaves a [git ] note. Acts on commits in the request body or reads the repo's recent log (GitCommandCommitReader) — wire it to a git hook or CI step. Co-Authored-By: Claude Opus 4.8 --- .../kotlin/com/correx/apps/server/Main.kt | 3 + .../com/correx/apps/server/ServerModule.kt | 3 + .../correx/apps/server/routes/TaskRoutes.kt | 29 +++++++ .../server/tasks/GitCommandCommitReader.kt | 56 ++++++++++++ .../apps/server/routes/TaskRoutesTest.kt | 21 +++++ .../tasks/GitCommandCommitReaderTest.kt | 30 +++++++ .../com/correx/core/tasks/CommitTaskParser.kt | 26 ++++++ .../com/correx/core/tasks/GitTaskSync.kt | 86 +++++++++++++++++++ .../correx/core/tasks/CommitTaskParserTest.kt | 29 +++++++ .../com/correx/core/tasks/GitTaskSyncTest.kt | 61 +++++++++++++ 10 files changed, 344 insertions(+) create mode 100644 apps/server/src/main/kotlin/com/correx/apps/server/tasks/GitCommandCommitReader.kt create mode 100644 apps/server/src/test/kotlin/com/correx/apps/server/tasks/GitCommandCommitReaderTest.kt create mode 100644 core/tasks/src/main/kotlin/com/correx/core/tasks/CommitTaskParser.kt create mode 100644 core/tasks/src/main/kotlin/com/correx/core/tasks/GitTaskSync.kt create mode 100644 core/tasks/src/test/kotlin/com/correx/core/tasks/CommitTaskParserTest.kt create mode 100644 core/tasks/src/test/kotlin/com/correx/core/tasks/GitTaskSyncTest.kt diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt index 3659d831..428d0482 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt @@ -227,6 +227,8 @@ fun main() { com.correx.apps.server.tasks.EventStoreTaskArtifactResolver(eventStore, artifactStore) val taskSessionResolver = com.correx.apps.server.tasks.SessionSummaryTaskSessionResolver(eventStore) + // Backs POST /tasks/sync-git: reads recent commits so a git hook / CI step can drive task status. + val gitCommitReader = com.correx.apps.server.tasks.GitCommandCommitReader(workspaceRoot) val taskService = TaskService(eventStore) val taskTools = TaskTools.forService( taskService, @@ -535,6 +537,7 @@ fun main() { taskDocumentResolver = taskDocumentResolver, taskArtifactResolver = taskArtifactResolver, taskSessionResolver = taskSessionResolver, + gitCommitReader = gitCommitReader, ) // Wire live config editing: persist to TOML, swap the holder, and rebuild config-derived // services. Built after the module so the rebuild hook can swap them in place. diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt b/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt index 9a95f434..c5a78882 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt @@ -123,6 +123,9 @@ class ServerModule( val taskArtifactResolver: com.correx.core.tasks.TaskArtifactResolver? = null, // Resolves linked agent sessions inline in the task context bundle. Null leaves them as raw links. val taskSessionResolver: com.correx.core.tasks.TaskSessionResolver? = null, + // Reads recent commits for POST /tasks/sync-git (git-driven status). Null disables the repo read + // (the endpoint then only acts on commits supplied in the request body). + val gitCommitReader: com.correx.core.tasks.GitCommitReader? = null, ) { val approvalCoordinator: ApprovalCoordinator = approvalCoordinator ?: ApprovalCoordinator( orchestrator = orchestrator, diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt b/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt index 88e7a656..7af9c9ab 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt @@ -2,6 +2,9 @@ package com.correx.apps.server.routes import com.correx.apps.server.ServerModule import com.correx.core.events.types.ProjectId +import com.correx.core.tasks.Commit +import com.correx.core.tasks.GitCommitReader +import com.correx.core.tasks.GitTaskSync import com.correx.core.events.types.TaskId import com.correx.core.events.types.TaskLinkType import com.correx.core.events.types.TaskNoteAuthor @@ -80,6 +83,16 @@ data class LinkRequest(val targetId: String, val type: String = "RELATES_TO", va @Serializable data class NoteRequest(val body: String, val author: String? = null) +@Serializable +data class CommitDto(val sha: String = "", val message: String) + +// When commits are supplied they are used verbatim; otherwise the server reads the last `limit` +// commits from the repo (when a GitCommitReader is wired). +@Serializable +data class SyncGitRequest(val commits: List = emptyList(), val limit: Int = DEFAULT_SYNC_LIMIT) + +private const val DEFAULT_SYNC_LIMIT = 20 + /** * Full REST surface for tasks. The write path mirrors the [TaskService] / `task_*` tools; both * append to the event log, which stays the single source of truth (nothing is stored separately). @@ -98,6 +111,7 @@ fun Route.taskRoutes(module: ServerModule) { ) route("/tasks") { taskCollectionRoutes(service) + taskSyncRoute(service, module.gitCommitReader) route("/{id}") { taskCrudRoutes(service, assembler) taskTransitionRoutes(service) @@ -138,6 +152,21 @@ private fun Route.taskCollectionRoutes(service: TaskService) { } } +// Git-driven status: a commit mention advances a task to IN_PROGRESS, a closing keyword +// ("fixes auth-12") advances it to DONE. Idempotent — wire this to a post-commit / post-merge hook +// or a CI step. Acts on commits in the body, or reads the repo's recent log when [reader] is set. +private fun Route.taskSyncRoute(service: TaskService, reader: GitCommitReader?) { + post("/sync-git") { + val body = runCatching { call.receive() }.getOrNull() ?: SyncGitRequest() + val commits = if (body.commits.isNotEmpty()) { + body.commits.map { Commit(it.sha, it.message) } + } else { + reader?.recent(body.limit) ?: emptyList() + } + call.respond(GitTaskSync(service).sync(commits)) + } +} + private fun Route.taskCrudRoutes(service: TaskService, assembler: TaskContextAssembler) { get { val id = taskId() ?: return@get call.respond(HttpStatusCode.BadRequest, "Missing task id") diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/tasks/GitCommandCommitReader.kt b/apps/server/src/main/kotlin/com/correx/apps/server/tasks/GitCommandCommitReader.kt new file mode 100644 index 00000000..c75091a7 --- /dev/null +++ b/apps/server/src/main/kotlin/com/correx/apps/server/tasks/GitCommandCommitReader.kt @@ -0,0 +1,56 @@ +package com.correx.apps.server.tasks + +import com.correx.core.tasks.Commit +import com.correx.core.tasks.GitCommitReader +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.nio.file.Path +import java.util.concurrent.TimeUnit + +private const val RS = "" // ASCII record separator, between commits +private const val US = "" // ASCII unit separator, between sha and body +private const val GIT_TIMEOUT_SEC = 10L + +/** + * Reads recent commits by shelling `git log` in the repo. Best-effort: any failure (git missing, + * not a repo, timeout, non-zero exit) yields an empty list so the sync endpoint degrades to a no-op + * rather than erroring. Parsing is split into the testable top-level [parseGitLog]. + */ +class GitCommandCommitReader(private val repoRoot: Path) : GitCommitReader { + + override suspend fun recent(limit: Int): List = withContext(Dispatchers.IO) { + runCatching { runGitLog(limit) }.getOrDefault(emptyList()) + } + + private fun runGitLog(limit: Int): List { + val process = ProcessBuilder( + "git", "-C", repoRoot.toString(), "log", "-n", limit.toString(), "--format=%H$US%B$RS", + ).redirectErrorStream(false).start() + val output = process.inputStream.bufferedReader().use { it.readText() } + val finished = process.waitFor(GIT_TIMEOUT_SEC, TimeUnit.SECONDS) + return if (finished && process.exitValue() == 0) { + parseGitLog(output) + } else { + if (!finished) process.destroy() + emptyList() + } + } +} + +/** + * Parses `git log --format=%H%B` output into commits: records split on the record + * separator, then each split once on the unit separator into sha + message. Top-level + internal + * so it is unit-testable without invoking git. + */ +internal fun parseGitLog(raw: String): List = + raw.split(RS) + .map { it.trim() } + .filter { it.isNotEmpty() } + .mapNotNull { record -> + val idx = record.indexOf(US) + if (idx < 0) { + null + } else { + Commit(sha = record.substring(0, idx).trim(), message = record.substring(idx + 1).trim()) + } + } diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/routes/TaskRoutesTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/routes/TaskRoutesTest.kt index 8a8823ae..d886345a 100644 --- a/apps/server/src/test/kotlin/com/correx/apps/server/routes/TaskRoutesTest.kt +++ b/apps/server/src/test/kotlin/com/correx/apps/server/routes/TaskRoutesTest.kt @@ -206,6 +206,27 @@ class TaskRoutesTest { } } + @Test + fun `POST sync-git advances tasks from commit messages`(@TempDir tempDir: Path) { + testApplication { + application { configureServer(buildModule(tempDir)) } + val client = createClient {} + client.createTask() // demo-1, TODO + + val res = client.post("/tasks/sync-git") { + contentType(ContentType.Application.Json) + setBody("""{"commits":[{"sha":"abc1234567","message":"fixes demo-1"}]}""") + } + assertEquals(HttpStatusCode.OK, res.status) + val change = (res.json()["changes"] as JsonArray).single() as JsonObject + assertEquals("demo-1", change["key"]!!.jsonPrimitive.content) + assertEquals("TODO", change["from"]!!.jsonPrimitive.content) + assertEquals("DONE", change["to"]!!.jsonPrimitive.content) + + assertEquals("DONE", client.get("/tasks/demo-1").json()["status"]!!.jsonPrimitive.content) + } + } + @Test fun `context bundle is served for a known task`(@TempDir tempDir: Path) { testApplication { diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/tasks/GitCommandCommitReaderTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/tasks/GitCommandCommitReaderTest.kt new file mode 100644 index 00000000..7efb3708 --- /dev/null +++ b/apps/server/src/test/kotlin/com/correx/apps/server/tasks/GitCommandCommitReaderTest.kt @@ -0,0 +1,30 @@ +package com.correx.apps.server.tasks + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class GitCommandCommitReaderTest { + + private val us = "" + private val rs = "" + + @Test + fun `parseGitLog splits records and sha from message`() { + val raw = "abc123${us}fix auth-1: rotate$rs\ndef456${us}wip on auth-2$rs\n" + + val commits = parseGitLog(raw) + + assertEquals(2, commits.size) + assertEquals("abc123", commits[0].sha) + assertEquals("fix auth-1: rotate", commits[0].message) + assertEquals("def456", commits[1].sha) + assertEquals("wip on auth-2", commits[1].message) + } + + @Test + fun `parseGitLog tolerates blank output and malformed records`() { + assertTrue(parseGitLog("").isEmpty()) + assertTrue(parseGitLog("no-separator-here$rs").isEmpty()) + } +} diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/CommitTaskParser.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/CommitTaskParser.kt new file mode 100644 index 00000000..7c1515f6 --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/CommitTaskParser.kt @@ -0,0 +1,26 @@ +package com.correx.core.tasks + +/** + * A task reference parsed from a commit message: the task key, and whether it was named with a + * closing keyword (close/fix/resolve …) — which drives the task to DONE rather than IN_PROGRESS. + */ +data class CommitTaskRef(val key: String, val closing: Boolean) + +/** + * Extracts task references from a commit message. A closing keyword before a key ("fixes auth-12") + * marks it closing; any other key mention ("see auth-12") is a plain reference; the closing form + * wins when a key appears both ways. Keys are matched loosely (`-`), which is safe because + * [GitTaskSync] only acts on keys that resolve to a real task — unknown matches are ignored. + */ +object CommitTaskParser { + private const val KEY = "[A-Za-z][A-Za-z0-9_]*-\\d+" + private val CLOSING = Regex("(?i)\\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\\b\\s+#?($KEY)") + private val MENTION = Regex("\\b($KEY)\\b") + + fun parse(message: String): List { + val byKey = LinkedHashMap() + CLOSING.findAll(message).forEach { byKey[it.groupValues[1]] = true } + MENTION.findAll(message).forEach { byKey.putIfAbsent(it.groupValues[1], false) } + return byKey.map { (key, closing) -> CommitTaskRef(key, closing) } + } +} diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/GitTaskSync.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/GitTaskSync.kt new file mode 100644 index 00000000..f4819c35 --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/GitTaskSync.kt @@ -0,0 +1,86 @@ +package com.correx.core.tasks + +import com.correx.core.events.types.TaskId +import com.correx.core.events.types.TaskNoteAuthor +import kotlinx.serialization.Serializable + +/** A single commit's identity + message — the unit [GitTaskSync] consumes. */ +data class Commit(val sha: String, val message: String) { + fun shortSha(): String = if (sha.length <= SHORT_SHA) sha else sha.substring(0, SHORT_SHA) + + private companion object { + const val SHORT_SHA = 7 + } +} + +/** + * Port for reading recent commits from the repo. Implemented in a higher layer (apps/server shells + * `git log`); core stays decoupled from process execution. + */ +interface GitCommitReader { + suspend fun recent(limit: Int): List +} + +@Serializable +data class TaskStatusChange(val key: String, val sha: String, val from: String, val to: String) + +@Serializable +data class GitSyncReport(val changes: List) + +/** + * Drives task status forward from git signals: a plain mention advances a task to IN_PROGRESS, a + * closing keyword advances it all the way to DONE (walking the legal claim → review → done path). + * It only ever advances — a task already at/past the target, or BLOCKED/CANCELLED, is left alone — + * so re-running over the same commits is idempotent. Each advance leaves a provenance note naming + * the commit. + */ +class GitTaskSync(private val service: TaskService) { + + suspend fun sync(commits: List): GitSyncReport { + val changes = mutableListOf() + for (commit in commits) { + for (ref in CommitTaskParser.parse(commit.message)) { + applyRef(ref, commit)?.let { changes += it } + } + } + return GitSyncReport(changes) + } + + private suspend fun applyRef(ref: CommitTaskRef, commit: Commit): TaskStatusChange? { + val taskId = TaskId(ref.key) + val from = service.getTask(taskId)?.state?.status ?: return null + val target = if (ref.closing) TaskStatus.DONE else TaskStatus.IN_PROGRESS + if (from !in ADVANCEABLE || rank(from) >= rank(target)) return null + + var current = from + while (current in ADVANCEABLE && rank(current) < rank(target)) { + advanceOne(taskId, current) + current = service.getTask(taskId)?.state?.status ?: break + } + if (current == from) return null + service.addNote(taskId, TaskNoteAuthor.AGENT, "[git ${commit.shortSha()}] ${from.name} → ${current.name}") + return TaskStatusChange(ref.key, commit.shortSha(), from.name, current.name) + } + + private suspend fun advanceOne(taskId: TaskId, current: TaskStatus) { + when (current) { + TaskStatus.TODO -> service.claim(taskId, GIT_CLAIMANT) + TaskStatus.IN_PROGRESS -> service.submitForReview(taskId) + TaskStatus.IN_REVIEW -> service.complete(taskId) + else -> Unit + } + } + + private fun rank(status: TaskStatus): Int = when (status) { + TaskStatus.TODO -> 0 + TaskStatus.IN_PROGRESS -> 1 + TaskStatus.IN_REVIEW -> 2 + TaskStatus.DONE -> 3 + else -> -1 // BLOCKED / CANCELLED sit off the forward path + } + + private companion object { + const val GIT_CLAIMANT = "git" + val ADVANCEABLE = setOf(TaskStatus.TODO, TaskStatus.IN_PROGRESS, TaskStatus.IN_REVIEW) + } +} diff --git a/core/tasks/src/test/kotlin/com/correx/core/tasks/CommitTaskParserTest.kt b/core/tasks/src/test/kotlin/com/correx/core/tasks/CommitTaskParserTest.kt new file mode 100644 index 00000000..f6fc1abf --- /dev/null +++ b/core/tasks/src/test/kotlin/com/correx/core/tasks/CommitTaskParserTest.kt @@ -0,0 +1,29 @@ +package com.correx.core.tasks + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class CommitTaskParserTest { + + @Test + fun `closing keywords mark refs closing`() { + val refs = CommitTaskParser.parse("Fixes auth-12 and resolves billing-3") + assertEquals(setOf(CommitTaskRef("auth-12", true), CommitTaskRef("billing-3", true)), refs.toSet()) + } + + @Test + fun `plain mentions are non-closing`() { + assertEquals(listOf(CommitTaskRef("auth-12", false)), CommitTaskParser.parse("see auth-12 for context")) + } + + @Test + fun `the closing form wins over a later plain mention of the same key`() { + assertEquals(listOf(CommitTaskRef("auth-1", true)), CommitTaskParser.parse("fix auth-1\n\nfollow-up to auth-1")) + } + + @Test + fun `a message with no keys yields empty`() { + assertTrue(CommitTaskParser.parse("just a normal commit message").isEmpty()) + } +} diff --git a/core/tasks/src/test/kotlin/com/correx/core/tasks/GitTaskSyncTest.kt b/core/tasks/src/test/kotlin/com/correx/core/tasks/GitTaskSyncTest.kt new file mode 100644 index 00000000..17044b8b --- /dev/null +++ b/core/tasks/src/test/kotlin/com/correx/core/tasks/GitTaskSyncTest.kt @@ -0,0 +1,61 @@ +package com.correx.core.tasks + +import com.correx.core.events.types.ProjectId +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class GitTaskSyncTest { + + private val store = InMemoryEventStore() + private val service = TaskService(store) + private val sync = GitTaskSync(service) + private val project = ProjectId("auth") + + @Test + fun `a closing keyword drives a task to DONE and re-running is idempotent`() = runBlocking { + val id = service.createTask(project, "JWT", "g").taskId // auth-1, TODO + + val report = sync.sync(listOf(Commit("abc1234567def", "fix auth-1: rotate tokens"))) + + assertEquals(TaskStatus.DONE, service.getTask(id)?.state?.status) + val change = report.changes.single() + assertEquals("auth-1", change.key) + assertEquals("TODO", change.from) + assertEquals("DONE", change.to) + assertEquals("abc1234", change.sha) + // The same commit applied again advances nothing. + assertTrue(sync.sync(listOf(Commit("abc1234567def", "fix auth-1"))).changes.isEmpty()) + } + + @Test + fun `a plain mention advances only to IN_PROGRESS`() = runBlocking { + val id = service.createTask(project, "JWT", "g").taskId + + sync.sync(listOf(Commit("def4567", "wip on auth-1"))) + + assertEquals(TaskStatus.IN_PROGRESS, service.getTask(id)?.state?.status) + } + + @Test + fun `blocked tasks and unknown keys are left alone`() = runBlocking { + val id = service.createTask(project, "JWT", "g").taskId + service.claim(id, "x") + service.block(id, "waiting on infra") + + val report = sync.sync(listOf(Commit("a1", "fix auth-1"), Commit("b2", "closes auth-999"))) + + assertEquals(TaskStatus.BLOCKED, service.getTask(id)?.state?.status) + assertTrue(report.changes.isEmpty()) + } + + @Test + fun `each advance leaves a git provenance note`() = runBlocking { + val id = service.createTask(project, "JWT", "g").taskId + + sync.sync(listOf(Commit("abc1234567", "closes auth-1"))) + + assertTrue(service.getTask(id)!!.state.notes.any { it.body.contains("git abc1234") }) + } +} From 8a6678f171b039af98d7c95d3d656f7e908431b4 Mon Sep 17 00:00:00 2001 From: kami Date: Wed, 24 Jun 2026 07:17:06 +0000 Subject: [PATCH 081/107] feat(tasks): markdown export projection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A disposable Markdown view of the board, grouped by status with checkboxes and claimants — rendered from the event log, never a source of truth. TaskMarkdown.render (pure) backs GET /tasks/export[?project=]. Co-Authored-By: Claude Opus 4.8 --- .../correx/apps/server/routes/TaskRoutes.kt | 9 ++++ .../apps/server/routes/TaskRoutesTest.kt | 15 ++++++ .../com/correx/core/tasks/TaskMarkdown.kt | 54 +++++++++++++++++++ .../com/correx/core/tasks/TaskMarkdownTest.kt | 33 ++++++++++++ 4 files changed, 111 insertions(+) create mode 100644 core/tasks/src/main/kotlin/com/correx/core/tasks/TaskMarkdown.kt create mode 100644 core/tasks/src/test/kotlin/com/correx/core/tasks/TaskMarkdownTest.kt diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt b/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt index 7af9c9ab..b690000b 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt @@ -11,14 +11,17 @@ import com.correx.core.events.types.TaskNoteAuthor import com.correx.core.events.types.TaskTargetKind import com.correx.core.tasks.Task import com.correx.core.tasks.TaskContextAssembler +import com.correx.core.tasks.TaskMarkdown import com.correx.core.tasks.TaskSearch import com.correx.core.tasks.TaskService import com.correx.core.tasks.TaskStatus import com.correx.core.tasks.TaskTargetKinds import com.correx.core.utils.TypeId +import io.ktor.http.ContentType import io.ktor.http.HttpStatusCode import io.ktor.server.request.receive import io.ktor.server.response.respond +import io.ktor.server.response.respondText import io.ktor.server.routing.Route import io.ktor.server.routing.RoutingContext import io.ktor.server.routing.delete @@ -150,6 +153,12 @@ private fun Route.taskCollectionRoutes(service: TaskService) { ) call.respond(HttpStatusCode.Created, task.toResponse()) } + // Disposable Markdown projection of the board (whole board, or one project with ?project=). + get("/export") { + val project = call.request.queryParameters["project"] + val tasks = if (project != null) service.list(ProjectId(project)) else service.listAll() + call.respondText(TaskMarkdown.render(tasks), ContentType.Text.Plain) + } } // Git-driven status: a commit mention advances a task to IN_PROGRESS, a closing keyword diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/routes/TaskRoutesTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/routes/TaskRoutesTest.kt index d886345a..beee6378 100644 --- a/apps/server/src/test/kotlin/com/correx/apps/server/routes/TaskRoutesTest.kt +++ b/apps/server/src/test/kotlin/com/correx/apps/server/routes/TaskRoutesTest.kt @@ -227,6 +227,21 @@ class TaskRoutesTest { } } + @Test + fun `GET tasks export renders the board as markdown`(@TempDir tempDir: Path) { + testApplication { + application { configureServer(buildModule(tempDir)) } + val client = createClient {} + client.createTask() // demo-1 + + val res = client.get("/tasks/export") + assertEquals(HttpStatusCode.OK, res.status) + val md = res.bodyAsText() + assertTrue(md.startsWith("# Tasks")) + assertTrue(md.contains("**demo-1**")) + } + } + @Test fun `context bundle is served for a known task`(@TempDir tempDir: Path) { testApplication { diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskMarkdown.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskMarkdown.kt new file mode 100644 index 00000000..d21c62d7 --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskMarkdown.kt @@ -0,0 +1,54 @@ +package com.correx.core.tasks + +/** + * Renders the task board to Markdown — a *disposable projection* of the event log, grouped by + * status. It is never a source of truth: regenerate it, don't hand-edit it. Pure and store-free + * so it can render either [TaskService.list] or [TaskService.listAll] and be unit tested directly. + */ +object TaskMarkdown { + + // Active work first, terminal states last. + private val ORDER = listOf( + TaskStatus.IN_PROGRESS, + TaskStatus.IN_REVIEW, + TaskStatus.BLOCKED, + TaskStatus.TODO, + TaskStatus.DONE, + TaskStatus.CANCELLED, + ) + + fun render(tasks: List): String = buildString { + appendLine("# Tasks") + appendLine() + appendLine("_Generated from the correx event log — disposable; regenerate, don't edit._") + appendLine() + if (tasks.isEmpty()) { + appendLine("_No tasks._") + return@buildString + } + val byStatus = tasks.groupBy { it.state.status } + for (status in ORDER) { + val group = byStatus[status]?.sortedBy { it.taskId.value }.orEmpty() + if (group.isEmpty()) continue + appendLine("## ${heading(status)} (${group.size})") + appendLine() + group.forEach { appendLine(item(it)) } + appendLine() + } + }.trimEnd() + "\n" + + private fun item(task: Task): String { + val check = if (task.state.status == TaskStatus.DONE) "x" else " " + val claim = task.state.claimant?.let { " _(@$it)_" }.orEmpty() + return "- [$check] **${task.taskId.value}** ${task.state.title.orEmpty()}$claim".trimEnd() + } + + private fun heading(status: TaskStatus): String = when (status) { + TaskStatus.TODO -> "To do" + TaskStatus.IN_PROGRESS -> "In progress" + TaskStatus.IN_REVIEW -> "In review" + TaskStatus.BLOCKED -> "Blocked" + TaskStatus.DONE -> "Done" + TaskStatus.CANCELLED -> "Cancelled" + } +} diff --git a/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskMarkdownTest.kt b/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskMarkdownTest.kt new file mode 100644 index 00000000..dfb7e0da --- /dev/null +++ b/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskMarkdownTest.kt @@ -0,0 +1,33 @@ +package com.correx.core.tasks + +import com.correx.core.events.types.TaskId +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class TaskMarkdownTest { + + private fun task(id: String, status: TaskStatus, title: String, claimant: String? = null) = + Task(TaskId(id), TaskState(key = id, status = status, title = title, claimant = claimant)) + + @Test + fun `renders grouped sections with checkboxes and claimants`() { + val md = TaskMarkdown.render( + listOf( + task("auth-1", TaskStatus.IN_PROGRESS, "JWT refresh", claimant = "claude-opus"), + task("auth-2", TaskStatus.DONE, "Login form"), + ), + ) + + assertTrue(md.startsWith("# Tasks")) + assertTrue(md.contains("disposable")) + assertTrue(md.contains("## In progress (1)")) + assertTrue(md.contains("- [ ] **auth-1** JWT refresh _(@claude-opus)_")) + assertTrue(md.contains("## Done (1)")) + assertTrue(md.contains("- [x] **auth-2** Login form")) + } + + @Test + fun `empty board renders a placeholder`() { + assertTrue(TaskMarkdown.render(emptyList()).contains("_No tasks._")) + } +} From f490968dc4c3a9576bb4870a3595454e4f776576 Mon Sep 17 00:00:00 2001 From: kami Date: Wed, 24 Jun 2026 07:17:06 +0000 Subject: [PATCH 082/107] feat(tasks): correx task CLI correx task list/search/show/create/claim/complete/export over the REST surface, with --json passthrough and pure render helpers. Completes the human entry points alongside the TUI board. Co-Authored-By: Claude Opus 4.8 --- .../kotlin/com/correx/apps/cli/CorrexCli.kt | 2 + .../correx/apps/cli/commands/TaskCommand.kt | 223 ++++++++++++++++++ .../apps/cli/commands/TaskRenderTest.kt | 45 ++++ 3 files changed, 270 insertions(+) create mode 100644 apps/cli/src/main/kotlin/com/correx/apps/cli/commands/TaskCommand.kt create mode 100644 apps/cli/src/test/kotlin/com/correx/apps/cli/commands/TaskRenderTest.kt diff --git a/apps/cli/src/main/kotlin/com/correx/apps/cli/CorrexCli.kt b/apps/cli/src/main/kotlin/com/correx/apps/cli/CorrexCli.kt index 8d7fd0e6..cd477b82 100644 --- a/apps/cli/src/main/kotlin/com/correx/apps/cli/CorrexCli.kt +++ b/apps/cli/src/main/kotlin/com/correx/apps/cli/CorrexCli.kt @@ -9,6 +9,7 @@ import com.correx.apps.cli.commands.RunCommand import com.correx.apps.cli.commands.SessionCommand import com.correx.apps.cli.commands.StatsCommand import com.correx.apps.cli.commands.StatusCommand +import com.correx.apps.cli.commands.TaskCommand import com.correx.apps.cli.commands.UndoCommand import com.github.ajalt.clikt.core.CliktCommand import com.github.ajalt.clikt.core.subcommands @@ -33,4 +34,5 @@ fun buildCli(): CorrexCli = CorrexCli().subcommands( ReplayCommand(), StatsCommand(), HealthCommand(), + TaskCommand(), ) diff --git a/apps/cli/src/main/kotlin/com/correx/apps/cli/commands/TaskCommand.kt b/apps/cli/src/main/kotlin/com/correx/apps/cli/commands/TaskCommand.kt new file mode 100644 index 00000000..3a75d00c --- /dev/null +++ b/apps/cli/src/main/kotlin/com/correx/apps/cli/commands/TaskCommand.kt @@ -0,0 +1,223 @@ +package com.correx.apps.cli.commands + +import com.correx.apps.cli.CorrexCli +import com.correx.apps.cli.DEFAULT_PORT +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.core.subcommands +import com.github.ajalt.clikt.parameters.arguments.argument +import com.github.ajalt.clikt.parameters.options.default +import com.github.ajalt.clikt.parameters.options.multiple +import com.github.ajalt.clikt.parameters.options.option +import com.github.ajalt.clikt.parameters.options.required +import io.ktor.client.HttpClient +import io.ktor.client.engine.cio.CIO +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.request.get +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType +import io.ktor.serialization.kotlinx.json.json +import java.io.File +import java.net.URLEncoder +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +private val taskJson = Json { ignoreUnknownKeys = true } + +/** Subset of the server's TaskResponse the CLI renders. Unknown fields are ignored. */ +@Serializable +data class TaskRow( + val id: String, + val key: String? = null, + val status: String = "", + val title: String? = null, + val goal: String? = null, + val acceptanceCriteria: List = emptyList(), + val affectedPaths: List = emptyList(), + val claimant: String? = null, + val links: List = emptyList(), + val notes: List = emptyList(), +) + +@Serializable +data class TaskLinkRow(val targetId: String, val type: String, val targetKind: String) + +@Serializable +data class TaskNoteRow(val author: String, val body: String) + +/** One line per task: id, status, title, and claimant. */ +fun renderTaskList(tasks: List): String { + if (tasks.isEmpty()) return "no tasks" + return tasks.joinToString("\n") { t -> + val claim = t.claimant?.let { " @$it" }.orEmpty() + "${t.id.padEnd(14)} ${t.status.padEnd(12)} ${t.title.orEmpty()}$claim".trimEnd() + } +} + +/** Full single-task view: goal, acceptance criteria, affected paths, links, notes. */ +fun renderTaskDetail(t: TaskRow): String = buildString { + appendLine("task ${t.id} [${t.status}] ${t.title.orEmpty()}".trimEnd()) + t.claimant?.let { appendLine("claimant: $it") } + t.goal?.let { appendLine("goal: $it") } + if (t.acceptanceCriteria.isNotEmpty()) { + appendLine("acceptance criteria:") + t.acceptanceCriteria.forEach { appendLine(" - $it") } + } + if (t.affectedPaths.isNotEmpty()) appendLine("affected paths: ${t.affectedPaths.joinToString(", ")}") + if (t.links.isNotEmpty()) { + appendLine("links:") + t.links.forEach { appendLine(" - ${it.targetId} (${it.type} -> ${it.targetKind})") } + } + if (t.notes.isNotEmpty()) { + appendLine("notes:") + t.notes.forEach { appendLine(" - [${it.author}] ${it.body}") } + } +}.trimEnd() + +class TaskCommand : CliktCommand(name = "task") { + override fun run() = Unit + + init { + subcommands( + TaskListCommand(), + TaskSearchCommand(), + TaskShowCommand(), + TaskCreateCommand(), + TaskClaimCommand(), + TaskCompleteCommand(), + TaskExportCommand(), + ) + } +} + +/** Shared host/port options + HTTP plumbing for the task subcommands. */ +abstract class TaskHttpCommand(name: String) : CliktCommand(name = name) { + protected val host by option("--host").default("localhost") + protected val port by option("--port").default("$DEFAULT_PORT") + + protected fun baseUrl(): String = "http://$host:${port.toIntOrNull() ?: DEFAULT_PORT}" + + protected fun jsonOut(): Boolean = (currentContext.findRoot().command as? CorrexCli)?.json ?: false + + protected fun http(block: suspend (HttpClient) -> Unit) = runBlocking { + val client = HttpClient(CIO) { install(ContentNegotiation) { json(taskJson) } } + runCatching { block(client) }.getOrElse { System.err.println("Error: ${it.message}") } + client.close() + } +} + +private fun enc(value: String): String = URLEncoder.encode(value, "UTF-8") + +class TaskListCommand : TaskHttpCommand("list") { + private val project by option("--project", help = "Limit to a project") + private val status by option("--status", help = "Filter by status (TODO, IN_PROGRESS, …)") + + override fun run() = http { client -> + val params = buildList { + project?.let { add("project=${enc(it)}") } + status?.let { add("status=${enc(it)}") } + } + val query = if (params.isEmpty()) "" else "?${params.joinToString("&")}" + val raw = client.get("${baseUrl()}/tasks$query").bodyAsText() + if (jsonOut()) println(raw) else println(renderTaskList(taskJson.decodeFromString(raw))) + } +} + +class TaskSearchCommand : TaskHttpCommand("search") { + private val query by argument("QUERY", help = "Search text (quote multi-word queries)") + private val project by option("--project", help = "Limit to a project") + + override fun run() = http { client -> + val params = buildList { + add("q=${enc(query)}") + project?.let { add("project=${enc(it)}") } + } + val raw = client.get("${baseUrl()}/tasks?${params.joinToString("&")}").bodyAsText() + if (jsonOut()) println(raw) else println(renderTaskList(taskJson.decodeFromString(raw))) + } +} + +class TaskShowCommand : TaskHttpCommand("show") { + private val id by argument("TASK_ID") + + override fun run() = http { client -> + val resp = client.get("${baseUrl()}/tasks/$id") + if (resp.status == HttpStatusCode.NotFound) { + System.err.println("No such task: $id") + } else { + val raw = resp.bodyAsText() + if (jsonOut()) println(raw) else println(renderTaskDetail(taskJson.decodeFromString(raw))) + } + } +} + +class TaskCreateCommand : TaskHttpCommand("create") { + private val project by option("--project", help = "Project key, e.g. 'auth'").required() + private val title by option("--title").required() + private val goal by option("--goal").required() + private val criteria by option("--criteria", help = "Acceptance criterion (repeatable)").multiple() + private val paths by option("--path", help = "Affected path/glob (repeatable)").multiple() + + override fun run() = http { client -> + val payload = buildJsonObject { + put("project", project) + put("title", title) + put("goal", goal) + if (criteria.isNotEmpty()) put("acceptanceCriteria", JsonArray(criteria.map { JsonPrimitive(it) })) + if (paths.isNotEmpty()) put("affectedPaths", JsonArray(paths.map { JsonPrimitive(it) })) + } + val raw = client.post("${baseUrl()}/tasks") { + contentType(ContentType.Application.Json) + setBody(payload) + }.bodyAsText() + if (jsonOut()) println(raw) else println("created ${taskJson.decodeFromString(raw).id}") + } +} + +class TaskClaimCommand : TaskHttpCommand("claim") { + private val id by argument("TASK_ID") + private val by by option("--by", help = "Claimant").required() + + override fun run() = http { client -> + val resp = client.post("${baseUrl()}/tasks/$id/claim") { + contentType(ContentType.Application.Json) + setBody(buildJsonObject { put("claimant", by) }) + } + if (resp.status == HttpStatusCode.NotFound) System.err.println("No such task: $id") else println("claimed $id") + } +} + +class TaskCompleteCommand : TaskHttpCommand("complete") { + private val id by argument("TASK_ID") + + override fun run() = http { client -> + val resp = client.post("${baseUrl()}/tasks/$id/complete") + if (resp.status == HttpStatusCode.NotFound) System.err.println("No such task: $id") else println("completed $id") + } +} + +class TaskExportCommand : TaskHttpCommand("export") { + private val project by option("--project", help = "Limit to a project") + private val out by option("--out", help = "Write to this file instead of stdout") + + override fun run() = http { client -> + val query = project?.let { "?project=${enc(it)}" }.orEmpty() + val markdown = client.get("${baseUrl()}/tasks/export$query").bodyAsText() + val target = out + if (target != null) { + File(target).writeText(markdown) + println("wrote $target") + } else { + print(markdown) + } + } +} diff --git a/apps/cli/src/test/kotlin/com/correx/apps/cli/commands/TaskRenderTest.kt b/apps/cli/src/test/kotlin/com/correx/apps/cli/commands/TaskRenderTest.kt new file mode 100644 index 00000000..11a11efd --- /dev/null +++ b/apps/cli/src/test/kotlin/com/correx/apps/cli/commands/TaskRenderTest.kt @@ -0,0 +1,45 @@ +package com.correx.apps.cli.commands + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class TaskRenderTest { + + private val task = TaskRow( + id = "auth-1", + key = "auth-1", + status = "IN_PROGRESS", + title = "JWT refresh", + goal = "users stay authenticated", + acceptanceCriteria = listOf("rotates"), + affectedPaths = listOf("backend/auth/**"), + claimant = "claude-opus", + links = listOf(TaskLinkRow("adr-7", "IMPLEMENTS", "DOC")), + notes = listOf(TaskNoteRow("AGENT", "kickoff")), + ) + + @Test + fun `renderTaskList shows one line per task with id, status, title`() { + val out = renderTaskList(listOf(task, TaskRow(id = "billing-3", status = "DONE", title = "Invoice"))) + val lines = out.lines() + assertEquals(2, lines.size) + assertTrue(lines[0].startsWith("auth-1")) + assertTrue(lines[0].contains("IN_PROGRESS") && lines[0].contains("JWT refresh") && lines[0].contains("@claude-opus")) + assertTrue(lines[1].contains("billing-3") && lines[1].contains("DONE")) + } + + @Test + fun `renderTaskList reports empty`() { + assertEquals("no tasks", renderTaskList(emptyList())) + } + + @Test + fun `renderTaskDetail includes goal, criteria, links and notes`() { + val out = renderTaskDetail(task) + assertTrue(out.startsWith("task auth-1 [IN_PROGRESS] JWT refresh")) + for (want in listOf("goal: users stay authenticated", "- rotates", "backend/auth/**", "adr-7 (IMPLEMENTS -> DOC)", "[AGENT] kickoff")) { + assertTrue(out.contains(want), "detail missing: $want") + } + } +} From 06c225e3bd4c80dd1431008cd52b6962e7e27d64 Mon Sep 17 00:00:00 2001 From: kami Date: Wed, 24 Jun 2026 09:40:44 +0000 Subject: [PATCH 083/107] feat(tasks): teach agents when to use the task tools The task tools were registered but had only mechanical descriptions and no standing policy, so agents knew the tools existed but not when to reach for them. Lead each of the five descriptions with its trigger (when to use, when not to) and add a task-tracking convention to .correx/project.toml, which is rendered into every stage's L0 context. Co-Authored-By: Claude Opus 4.8 --- .correx/project.toml | 1 + .../correx/infrastructure/tools/task/TaskContextTool.kt | 5 +++-- .../com/correx/infrastructure/tools/task/TaskCreateTool.kt | 5 ++++- .../com/correx/infrastructure/tools/task/TaskDeleteTool.kt | 5 +++-- .../com/correx/infrastructure/tools/task/TaskSearchTool.kt | 6 ++++-- .../com/correx/infrastructure/tools/task/TaskUpdateTool.kt | 7 ++++--- 6 files changed, 19 insertions(+), 10 deletions(-) diff --git a/.correx/project.toml b/.correx/project.toml index 3556a92d..3bf52623 100644 --- a/.correx/project.toml +++ b/.correx/project.toml @@ -5,6 +5,7 @@ conventions = [ "Dependency direction: apps -> core -> infrastructure; no cross-core imports", "No bare try-catch; use runCatching and sealed domain error types", "Every new EventPayload must be registered in the eventModule polymorphic block (Serialization.kt)", + "Track multi-session or handoff work as native tasks: search before creating to avoid duplicates (task_search), task_context before starting, claim before working, submit_for_review when ready, complete after review. Don't open a task for a single self-contained edit you finish now.", ] [commands] diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskContextTool.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskContextTool.kt index 38953f8d..cdb121a9 100644 --- a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskContextTool.kt +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskContextTool.kt @@ -25,8 +25,9 @@ class TaskContextTool(private val assembler: TaskContextAssembler) : Tool, ToolE override val name: String = "task_context" override val description: String = - "Fetch a task's context bundle (goal, acceptance criteria, resolved dependencies, " + - "related links, relevant files, notes) in one call. Use before working a task." + "Call this first, before working a task you've claimed or been pointed at, so you don't " + + "re-derive context: it returns the task's goal, acceptance criteria, resolved " + + "dependencies, related links, relevant files, and notes in one bundle." override val parametersSchema: JsonObject = buildJsonObject { put("type", "object") putJsonObject("properties") { diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskCreateTool.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskCreateTool.kt index d1f5b90c..357860d3 100644 --- a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskCreateTool.kt +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskCreateTool.kt @@ -22,7 +22,10 @@ class TaskCreateTool(private val service: TaskService) : Tool, ToolExecutor { override val name: String = "task_create" override val description: String = - "Create a task in a project's work graph and return its id (e.g. 'auth-142')." + "Open a task when work spans multiple sessions, needs handoff between runs, or the " + + "operator asks to track it. Search first (task_search) to avoid duplicates. Don't " + + "create one for a single self-contained edit you finish now. Returns the new id " + + "(e.g. 'auth-142')." override val parametersSchema: JsonObject = buildJsonObject { put("type", "object") putJsonObject("properties") { diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskDeleteTool.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskDeleteTool.kt index 0442fe8a..b47eded1 100644 --- a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskDeleteTool.kt +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskDeleteTool.kt @@ -26,8 +26,9 @@ class TaskDeleteTool(private val service: TaskService) : Tool, ToolExecutor { override val name: String = "task_delete" override val description: String = - "Soft-delete a task (removes it from active views; history is kept). " + - "For an abandoned-but-visible task, use task_update action=cancel instead." + "Use only to remove a task opened by mistake or a duplicate: soft-deletes it (drops " + + "from active views; history is kept). For real work that's been abandoned, use " + + "task_update action=cancel instead so it stays visible." override val parametersSchema: JsonObject = buildJsonObject { put("type", "object") putJsonObject("properties") { diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskSearchTool.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskSearchTool.kt index a4b80c39..dc287d4e 100644 --- a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskSearchTool.kt +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskSearchTool.kt @@ -28,8 +28,10 @@ class TaskSearchTool(private val service: TaskService) : Tool, ToolExecutor { override val name: String = "task_search" override val description: String = - "Search tasks by text (matches id/title/goal/acceptance criteria/notes), ranked by " + - "relevance. Returns 'id [STATUS] title' lines. Use to find a task id, then task_context." + "Search tasks before opening a new one (avoid duplicates) and to find related or " + + "blocking work, or to look up a task id by text. Matches id/title/goal/acceptance " + + "criteria/notes, ranked by relevance; returns 'id [STATUS] title' lines. Pair with " + + "task_context to load a hit." override val parametersSchema: JsonObject = buildJsonObject { put("type", "object") putJsonObject("properties") { diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt index 7b0607b2..bf37d5d7 100644 --- a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt @@ -29,9 +29,10 @@ class TaskUpdateTool(private val service: TaskService) : Tool, ToolExecutor { override val name: String = "task_update" override val description: String = - "Update a task: edit title/goal/criteria/paths, change status via 'action' " + - "(claim, release, block, unblock, submit_for_review, complete, reopen, cancel), " + - "add a link, or add a note." + "Keep a task's state current as you work it: claim before starting, submit_for_review " + + "when ready, complete after review, block/unblock when waiting on something. Also " + + "edits title/goal/criteria/paths, adds a work-graph link, or attaches a note. " + + "Actions: claim, release, block, unblock, submit_for_review, complete, reopen, cancel." override val parametersSchema: JsonObject = buildJsonObject { put("type", "object") putJsonObject("properties") { From afb536f21f2804ac9efbbbd39057669c48c144df Mon Sep 17 00:00:00 2001 From: kami Date: Wed, 24 Jun 2026 10:04:01 +0000 Subject: [PATCH 084/107] feat(tasks): wire the task tools into role_pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tool availability is a strict per-stage allowlist, so the task doctrine was inert in role_pipeline — no stage granted the tools. Add them where they fit each stage's tier and role, and point the stage prompts at the lifecycle: - analyst (read-only): task_search/task_context to find related or duplicate work and ground the analysis. - implementer: full set — claim before working, submit_for_review once verification passes, create one if the work warrants tracking. - reviewer: task_context to judge against the task's own criteria, task_update to complete it on an approved verdict. Co-Authored-By: Claude Opus 4.8 --- examples/workflows/prompts/analyst.md | 5 ++++- examples/workflows/prompts/implementer.md | 12 ++++++++---- examples/workflows/prompts/reviewer.md | 5 +++++ examples/workflows/role_pipeline.toml | 15 +++++++++++++-- 4 files changed, 30 insertions(+), 7 deletions(-) diff --git a/examples/workflows/prompts/analyst.md b/examples/workflows/prompts/analyst.md index 477a18a7..b96e0bf7 100644 --- a/examples/workflows/prompts/analyst.md +++ b/examples/workflows/prompts/analyst.md @@ -8,7 +8,10 @@ Steps: 1. Read the user's request carefully. Restate what is actually being asked. 2. Use `file_read` and shell (read-only: `ls`, `grep`, `cat`, `find`) to locate the relevant code. Identify the files, modules, and subsystems involved. Do not modify anything. -3. Derive concrete, checkable requirements and acceptance criteria. +3. Check for existing work: `task_search` for related, duplicate, or blocking tasks, and + `task_context` to load any the request names. Fold what you find into the analysis rather + than re-deriving it; flag a duplicate instead of restating it. +4. Derive concrete, checkable requirements and acceptance criteria. The decision history above (steering, approvals, prior verdicts) is ground truth — honour it. diff --git a/examples/workflows/prompts/implementer.md b/examples/workflows/prompts/implementer.md index d639ba0e..6e117c78 100644 --- a/examples/workflows/prompts/implementer.md +++ b/examples/workflows/prompts/implementer.md @@ -4,12 +4,16 @@ You receive the `impl_plan` artifact (above). Execute it using the tools availab (`file_read`, `file_write`, `file_edit`, and shell). File writes land in the bound workspace. Steps: -1. Work through the plan `steps` in order. Read before you edit. -2. Make the change with `file_write` / `file_edit`. Keep new code consistent with the +1. If this work is tracked as a task — or warrants it per the task policy in the context above — + `task_context` to load it and `task_update action=claim` before you start; `task_create` one + if none exists. Skip this for a self-contained change. +2. Work through the plan `steps` in order. Read before you edit. +3. Make the change with `file_write` / `file_edit`. Keep new code consistent with the surrounding style, naming, and patterns. -3. Run the plan's `verification` commands (build/tests) via shell and fix what fails. Do not +4. Run the plan's `verification` commands (build/tests) via shell and fix what fails. Do not leave a step in a broken state. -4. When every step is done and verification passes, call the `stage_complete` tool. +5. When every step is done and verification passes, `task_update action=submit_for_review` on the + task (if any), then call the `stage_complete` tool. The decision history above is ground truth. **If the reviewer requested changes** in a prior round, you will see that verdict and its notes above — address those specific points; do not diff --git a/examples/workflows/prompts/reviewer.md b/examples/workflows/prompts/reviewer.md index 29bd1f74..04d87e18 100644 --- a/examples/workflows/prompts/reviewer.md +++ b/examples/workflows/prompts/reviewer.md @@ -29,5 +29,10 @@ Emit your result as the `review_report` artifact (JSON, schema provided): line), each tied to the requirement or plan step it violates, so the implementer knows exactly what to fix. If `approved`, briefly state which requirements the patch satisfies. +If the work is tracked as a task, reflect your verdict on it (use `task_context` first if you +need its own acceptance criteria): on `approved`, `task_update action=complete`; on +`changes_requested`, leave it claimed for the implementer — optionally add a `note` summarising +what's needed. + Use `changes_requested` only for real problems — the loop is capped and will escalate to a human if it runs too long. Be decisive. diff --git a/examples/workflows/role_pipeline.toml b/examples/workflows/role_pipeline.toml index e62175a1..e8e77e62 100644 --- a/examples/workflows/role_pipeline.toml +++ b/examples/workflows/role_pipeline.toml @@ -27,11 +27,13 @@ start = "analyst" # 1. Understand the request and the relevant code. Read-only. # ground_references: every workspace-relative file path the analysis names is checked # for existence; a hallucinated path fails the stage and retries with the misses fed back. +# task_search/task_context (read-only) let it find related or duplicate tasks and load +# their context, so the analysis is grounded in existing work rather than re-derived. [[stages]] id = "analyst" prompt = "prompts/analyst.md" produces = [{ name = "analysis", kind = "analysis" }] -allowed_tools = ["file_read", "ShellTool"] +allowed_tools = ["file_read", "ShellTool", "task_search", "task_context"] ground_references = true token_budget = 16384 max_retries = 2 @@ -60,12 +62,18 @@ max_retries = 2 # stage may write — a FILE_WRITE outside it is blocked as scope creep. Left open # here because the targets are task-specific; a task-scoped workflow would set e.g. # writes = ["core/sessions/**", "testing/sessions/**"] +# The task tools let it own the work item across the loop: claim before starting, +# submit_for_review once verification passes, and add notes (create one first if the +# work warrants tracking and none exists — see the task policy in .correx/project.toml). [[stages]] id = "implementer" prompt = "prompts/implementer.md" needs = ["impl_plan"] produces = [{ name = "patch", kind = "file_written" }] -allowed_tools = ["file_read", "file_write", "file_edit", "ShellTool"] +allowed_tools = [ + "file_read", "file_write", "file_edit", "ShellTool", + "task_create", "task_update", "task_context", "task_search", +] token_budget = 32768 max_retries = 3 @@ -85,11 +93,14 @@ max_retries = 0 # 5. Review the patch against the plan AND the analyst's acceptance criteria. The reviewer # needs `analysis` so it judges the diff against concrete, pre-stated criteria (§5 narrow # question) rather than whole files against taste. +# 5b. task_context lets the reviewer judge the patch against the task's own acceptance +# criteria; task_update lets it complete the task on an approved verdict (and only then). [[stages]] id = "reviewer" prompt = "prompts/reviewer.md" needs = ["patch", "impl_plan", "analysis"] produces = [{ name = "review_report", kind = "review_report" }] +allowed_tools = ["task_context", "task_update"] token_budget = 32768 max_retries = 2 From de54be7ecbd081d65a02b6e3eb0722aaeb3968de Mon Sep 17 00:00:00 2001 From: kami Date: Wed, 24 Jun 2026 10:26:38 +0000 Subject: [PATCH 085/107] feat(tasks): wire the task tools into the remaining code-work workflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the role_pipeline wiring to the other workflows that act on a code work item, matching each stage's tier: - freestyle_planning: analyst (read-only) gets task_search/task_context to find related work and ground the analysis; prompt updated to match. - review_loop: implement gets the full set (claim, submit_for_review, notes), review gets task_context/task_update to complete on an approved verdict. Its prompt files don't ship, so the doctrine rides the L0 policy + tool descriptions. Left untouched: research (external-research flow producing a report, not a code work item), qa_ping (smoke test), and healthcheck (diagnostic) — none track work. Co-Authored-By: Claude Opus 4.8 --- examples/workflows/freestyle_planning.toml | 4 +++- examples/workflows/prompts/analyst_freestyle.md | 4 ++++ examples/workflows/review_loop.toml | 7 ++++++- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/examples/workflows/freestyle_planning.toml b/examples/workflows/freestyle_planning.toml index f97603fd..325088b1 100644 --- a/examples/workflows/freestyle_planning.toml +++ b/examples/workflows/freestyle_planning.toml @@ -1,11 +1,13 @@ id = "freestyle_planning" start = "analyst" +# analyst is read-only, so it gets only the read-only task tools: task_search to find related +# or duplicate work and task_context to ground the analysis in an existing task. [[stages]] id = "analyst" prompt = "prompts/analyst_freestyle.md" produces = [{ name = "analysis", kind = "analysis" }] -allowed_tools = ["file_read", "ShellTool"] +allowed_tools = ["file_read", "ShellTool", "task_search", "task_context"] token_budget = 16384 max_retries = 2 diff --git a/examples/workflows/prompts/analyst_freestyle.md b/examples/workflows/prompts/analyst_freestyle.md index c0eb7b0f..d1452474 100644 --- a/examples/workflows/prompts/analyst_freestyle.md +++ b/examples/workflows/prompts/analyst_freestyle.md @@ -2,6 +2,10 @@ You are the **Analyst** in freestyle mode. Understand the user's goal (in the de above) and the code it touches. Read-only: `file_read` (also lists a directory's entries when given a directory path), `ls`, `grep`, `cat`, `find`. +Before deriving requirements, check for existing work: `task_search` for related, duplicate, or +blocking tasks and `task_context` to load any the goal names. Fold what you find into the +analysis rather than re-deriving it; flag a duplicate instead of restating it. + Emit the `analysis` artifact (JSON, schema provided): - `summary`: the goal in your own words. - `requirements`: concrete, checkable requirements, one per line. diff --git a/examples/workflows/review_loop.toml b/examples/workflows/review_loop.toml index 002029af..0bbd554a 100644 --- a/examples/workflows/review_loop.toml +++ b/examples/workflows/review_loop.toml @@ -14,19 +14,24 @@ id = "review_loop" start = "implement" +# implement owns the work item across the loop: claim before starting, submit_for_review once +# the patch is ready, add notes (task_create one first if the work warrants tracking). [[stages]] id = "implement" prompt = "prompts/implement.md" produces = [{ name = "patch", kind = "file_written" }] -allowed_tools = ["file_write"] +allowed_tools = ["file_write", "task_create", "task_update", "task_context", "task_search"] token_budget = 8192 max_retries = 3 +# review reads the task's own acceptance criteria (task_context) and completes it on an approved +# verdict (task_update action=complete); on changes_requested it leaves the task claimed. [[stages]] id = "review" prompt = "prompts/review.md" needs = ["patch"] produces = [{ name = "review_report", kind = "review_report" }] +allowed_tools = ["task_context", "task_update"] token_budget = 8192 max_retries = 3 From 12d5b9d7dc0891fa8dbb7da79c19d36afd60846b Mon Sep 17 00:00:00 2001 From: kami Date: Wed, 24 Jun 2026 15:13:52 +0000 Subject: [PATCH 086/107] feat(tasks): analyst opens the task; freestyle threads it into implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two coupled gaps from tracing a real run: 1. Freestyle implements in phase 2 via stages compiled from the architect's execution_plan (ExecutionPlanCompiler sets allowedTools = stage.tools), so the static allow-lists never reach it and architect_freestyle.md banned every tool but the file four. Teach the architect to thread an analysis-referenced task through the plan: implementing stages get task_context/task_update and claim + submit_for_review; the final/review stage completes it. No task referenced → no task tools, and the plan never creates one. 2. Give the analyst task_create so the work is framed as a tracked item up front (role_pipeline + freestyle). "Read-only" for the analyst means it writes no files; a task is an event-log entry, not a file write — task_create is T2, so opening one is approval-gated. The analyst names the new id in the analysis so the implementer claims it and the reviewer completes it; the implementer now creates only as a fallback. Co-Authored-By: Claude Opus 4.8 --- examples/workflows/freestyle_planning.toml | 8 +++++--- examples/workflows/prompts/analyst.md | 4 +++- .../workflows/prompts/analyst_freestyle.md | 5 ++++- .../workflows/prompts/architect_freestyle.md | 18 +++++++++++++++--- examples/workflows/prompts/implementer.md | 6 +++--- examples/workflows/role_pipeline.toml | 13 +++++++------ 6 files changed, 37 insertions(+), 17 deletions(-) diff --git a/examples/workflows/freestyle_planning.toml b/examples/workflows/freestyle_planning.toml index 325088b1..3eec738e 100644 --- a/examples/workflows/freestyle_planning.toml +++ b/examples/workflows/freestyle_planning.toml @@ -1,13 +1,15 @@ id = "freestyle_planning" start = "analyst" -# analyst is read-only, so it gets only the read-only task tools: task_search to find related -# or duplicate work and task_context to ground the analysis in an existing task. +# analyst writes no files, but it owns task framing: task_search/task_context (read-only) find +# existing work, and task_create (T2, approval-gated — a task is an event-log entry, not a file +# write) opens a task for this work and names its id in the analysis, so the architect can thread +# it into the plan's implementation stages. [[stages]] id = "analyst" prompt = "prompts/analyst_freestyle.md" produces = [{ name = "analysis", kind = "analysis" }] -allowed_tools = ["file_read", "ShellTool", "task_search", "task_context"] +allowed_tools = ["file_read", "ShellTool", "task_search", "task_context", "task_create"] token_budget = 16384 max_retries = 2 diff --git a/examples/workflows/prompts/analyst.md b/examples/workflows/prompts/analyst.md index b96e0bf7..7fb48c14 100644 --- a/examples/workflows/prompts/analyst.md +++ b/examples/workflows/prompts/analyst.md @@ -10,7 +10,9 @@ Steps: code. Identify the files, modules, and subsystems involved. Do not modify anything. 3. Check for existing work: `task_search` for related, duplicate, or blocking tasks, and `task_context` to load any the request names. Fold what you find into the analysis rather - than re-deriving it; flag a duplicate instead of restating it. + than re-deriving it; flag a duplicate instead of restating it. If this work warrants tracking + (per the task policy) and no task covers it, `task_create` one and name its id in the analysis + so the implementer claims it and the reviewer completes it. 4. Derive concrete, checkable requirements and acceptance criteria. The decision history above (steering, approvals, prior verdicts) is ground truth — honour it. diff --git a/examples/workflows/prompts/analyst_freestyle.md b/examples/workflows/prompts/analyst_freestyle.md index d1452474..f5fb363a 100644 --- a/examples/workflows/prompts/analyst_freestyle.md +++ b/examples/workflows/prompts/analyst_freestyle.md @@ -4,7 +4,10 @@ given a directory path), `ls`, `grep`, `cat`, `find`. Before deriving requirements, check for existing work: `task_search` for related, duplicate, or blocking tasks and `task_context` to load any the goal names. Fold what you find into the -analysis rather than re-deriving it; flag a duplicate instead of restating it. +analysis rather than re-deriving it; flag a duplicate instead of restating it. If a task already +covers this work, name its id (e.g. `auth-142`) in the analysis; if none does and the work +warrants tracking (per the task policy), `task_create` one and name its id — either way later +stages thread it through the plan. Emit the `analysis` artifact (JSON, schema provided): - `summary`: the goal in your own words. diff --git a/examples/workflows/prompts/architect_freestyle.md b/examples/workflows/prompts/architect_freestyle.md index 2487e8e8..f97b2844 100644 --- a/examples/workflows/prompts/architect_freestyle.md +++ b/examples/workflows/prompts/architect_freestyle.md @@ -57,9 +57,21 @@ Emit a JSON object that validates against the `execution_plan` schema: llm-emitted kind. - Declare `needs`: every upstream artifact id the stage's prompt references. Every id in `needs` must be `produces`d by a strictly earlier stage. -- Include `tools` only for stages that write or edit files: - `["file_read", "file_write", "file_edit", "ShellTool"]`. Do not invent tool names - beyond this set. +- Include `tools` per stage as it needs them, using only names from this set: + `file_read`, `file_write`, `file_edit`, `ShellTool`, `task_context`, `task_update`, + `task_search`. Stages that write or edit files take the file set + (`["file_read", "file_write", "file_edit", "ShellTool"]`). Do not invent names beyond + this set. +- **Task tracking — only if the `analysis` references a task** (an id like `auth-142` that + the analyst found or opened with `task_create`; if none is referenced there is no task to + track). When one is referenced, thread it through the plan so the work stays tracked: + - Give the stage that does the work `task_context` and `task_update`, and have its + `prompt` `task_update action=claim` the task before starting and + `action=submit_for_review` when its output is ready. + - Give the final or review stage `task_context` and `task_update`, and have its `prompt` + `task_update action=complete` the task once the work is accepted. + - If the `analysis` references no task, omit the task tools entirely. Do not create a + new task here — creation is out of scope for the plan. - Keep stages small and single-responsibility. Prefer more stages over large monolithic prompts. diff --git a/examples/workflows/prompts/implementer.md b/examples/workflows/prompts/implementer.md index 6e117c78..54f4b63c 100644 --- a/examples/workflows/prompts/implementer.md +++ b/examples/workflows/prompts/implementer.md @@ -4,9 +4,9 @@ You receive the `impl_plan` artifact (above). Execute it using the tools availab (`file_read`, `file_write`, `file_edit`, and shell). File writes land in the bound workspace. Steps: -1. If this work is tracked as a task — or warrants it per the task policy in the context above — - `task_context` to load it and `task_update action=claim` before you start; `task_create` one - if none exists. Skip this for a self-contained change. +1. If the analysis opened or referenced a task, `task_context` to load it and `task_update + action=claim` before you start (`task_create` one only if the work warrants tracking and none + exists). Skip this for a self-contained change. 2. Work through the plan `steps` in order. Read before you edit. 3. Make the change with `file_write` / `file_edit`. Keep new code consistent with the surrounding style, naming, and patterns. diff --git a/examples/workflows/role_pipeline.toml b/examples/workflows/role_pipeline.toml index e8e77e62..2ed019bf 100644 --- a/examples/workflows/role_pipeline.toml +++ b/examples/workflows/role_pipeline.toml @@ -24,16 +24,17 @@ id = "role_pipeline" start = "analyst" -# 1. Understand the request and the relevant code. Read-only. -# ground_references: every workspace-relative file path the analysis names is checked -# for existence; a hallucinated path fails the stage and retries with the misses fed back. -# task_search/task_context (read-only) let it find related or duplicate tasks and load -# their context, so the analysis is grounded in existing work rather than re-derived. +# 1. Understand the request and the relevant code — writes no files (ground_references checks +# every workspace path the analysis names; a hallucinated path fails the stage and retries). +# It also owns task framing: task_search/task_context (read-only) find existing work, and +# task_create (T2, approval-gated — a task is an event-log entry, not a file write) opens a +# task for this work up front, named in the analysis so the implementer claims it and the +# reviewer completes it. [[stages]] id = "analyst" prompt = "prompts/analyst.md" produces = [{ name = "analysis", kind = "analysis" }] -allowed_tools = ["file_read", "ShellTool", "task_search", "task_context"] +allowed_tools = ["file_read", "ShellTool", "task_search", "task_context", "task_create"] ground_references = true token_budget = 16384 max_retries = 2 From d12d64e4fe6237b8bff403afb7f2cf4220ae01c3 Mon Sep 17 00:00:00 2001 From: kami Date: Wed, 24 Jun 2026 16:16:03 +0000 Subject: [PATCH 087/107] feat(workflow): reject execution plans that reference unknown tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A freestyle stage's tools are LLM-authored in the execution_plan. At run time an unresolvable tool name is silently dropped (resolve -> null -> mapNotNull), so the stage runs toolless — e.g. a misspelled task_update means the implementation stage quietly loses task tracking, invisible without a live run. ExecutionPlanCompiler now takes the registered-tool universe and rejects a plan that names an unknown tool, with a message identifying the tool and stage. FreestyleDriver already turns a compile failure into a rejection the architect retries. The param defaults to empty (validation skipped) so existing callers are unaffected; Main feeds it toolRegistry.all(). Co-Authored-By: Claude Opus 4.8 --- .../kotlin/com/correx/apps/server/Main.kt | 2 +- .../workflow/ExecutionPlanCompiler.kt | 23 +++++++++ .../workflow/ExecutionPlanCompilerTest.kt | 49 +++++++++++++++++++ 3 files changed, 73 insertions(+), 1 deletion(-) diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt index 428d0482..849a9866 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt @@ -457,7 +457,7 @@ fun main() { val freestyleDriver = FreestyleDriver( eventStore = eventStore, - compiler = ExecutionPlanCompiler(artifactKindRegistry), + compiler = ExecutionPlanCompiler(artifactKindRegistry, toolRegistry.all().map { it.name }.toSet()), planContent = { sid -> orchestrator.validatedArtifactContent(sid, ArtifactId("execution_plan")) }, config = defaultOrchestrationConfig, runPhase2 = { sid, graph, cfg -> orchestrator.run(sid, graph, cfg) }, diff --git a/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompiler.kt b/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompiler.kt index 7d0f33f9..0e1177cd 100644 --- a/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompiler.kt +++ b/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompiler.kt @@ -17,6 +17,12 @@ private const val TERMINAL = "done" class ExecutionPlanCompiler( private val registry: ArtifactKindRegistry, + // Names of every registered tool. A stage that references a tool the runtime can't resolve + // is dropped silently at run time (mapNotNull), so the stage runs toolless — invisible + // without a live run. Validating here turns that into a compile-time rejection (which the + // freestyle driver feeds back for a retry). Empty = the caller didn't supply the tool + // universe, so validation is skipped (preserves the bare `ExecutionPlanCompiler(registry)`). + private val knownTools: Set = emptySet(), ) { private val mapper = JsonMapper.builder() .addModule(kotlinModule()) @@ -27,6 +33,7 @@ class ExecutionPlanCompiler( val plan = runCatching { mapper.readValue(planJson) } .getOrElse { throw WorkflowValidationException("Failed to parse execution_plan JSON: ${it.message}") } if (plan.stages.isEmpty()) throw WorkflowValidationException("execution_plan has no stages") + validateTools(plan) val stageMap = plan.stages.associate { s -> // `produces` is the unique slot name referenced by needs/edges; `kind` selects the @@ -81,6 +88,22 @@ class ExecutionPlanCompiler( return WorkflowGraph(id = workflowId, stages = stageMap, transitions = transitions, start = start) } + /** + * Every tool a stage names must be a registered tool, else the runtime silently drops it + * (resolve → null → mapNotNull) and the stage runs without it. Skipped when [knownTools] is + * empty (the caller didn't supply the tool universe). + */ + private fun validateTools(plan: ExecutionPlanModel) { + if (knownTools.isEmpty()) return + val offenders = plan.stages.flatMap { s -> s.tools.filter { it !in knownTools }.map { s.id to it } } + if (offenders.isEmpty()) return + val detail = offenders.joinToString(", ") { (stage, tool) -> "'$tool' in stage '$stage'" } + throw WorkflowValidationException( + "execution_plan references unknown tool(s): $detail — valid tools: " + + knownTools.sorted().joinToString(", "), + ) + } + /** * A field-equals edge can only ever fire if the producing stage's kind schema declares * that field — the kind's schema is also the LLM response format, so an undeclared field diff --git a/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompilerTest.kt b/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompilerTest.kt index 6d2b24da..c0f9ed18 100644 --- a/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompilerTest.kt +++ b/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompilerTest.kt @@ -273,4 +273,53 @@ class ExecutionPlanCompilerTest { fun `malformed JSON throws WorkflowValidationException`() { assertThrows { compiler.compile("not-json", "bad-workflow") } } + + // A compiler that knows the tool universe, so plan tool names are validated. + private val toolAware = ExecutionPlanCompiler( + registry, + knownTools = setOf("ShellTool", "file_write", "task_context", "task_update"), + ) + + @Test + fun `unknown tool name throws WorkflowValidationException naming the offender`() { + // The runtime would silently drop a misspelled tool; here it fails to compile instead. + val bad = validPlan.replace("\"tools\": [\"ShellTool\"]", "\"tools\": [\"task_updates\"]") + val ex = assertThrows { toolAware.compile(bad, "bad-workflow") } + assertEquals(true, ex.message?.contains("task_updates")) + assertEquals(true, ex.message?.contains("analyse")) + } + + @Test + fun `plan threading registered task tools compiles`() { + val plan = """ + { + "goal": "implement and review a tracked task", + "stages": [ + { + "id": "implement", "prompt": "claim auth-1, build, submit", "produces": "patch", + "needs": [], "tools": ["file_write", "task_context", "task_update"] + }, + { + "id": "review", "prompt": "review and complete auth-1", "produces": "patch", + "needs": ["patch"], "tools": ["task_context", "task_update"] + } + ], + "edges": [ + { "from": "implement", "to": "review", "condition": { "type": "always_true" } }, + { "from": "review", "to": "done", "condition": { "type": "always_true" } } + ] + } + """.trimIndent() + val graph = toolAware.compile(plan, "tracked-workflow") + val implement = graph.stages.values.first { it.metadata["promptInline"] == "claim auth-1, build, submit" } + assertTrue(implement.allowedTools.containsAll(setOf("task_context", "task_update"))) + } + + @Test + fun `tool validation is skipped when the tool universe is unknown`() { + // The default compiler has no knownTools, so an arbitrary tool name still compiles. + val plan = validPlan.replace("\"tools\": [\"ShellTool\"]", "\"tools\": [\"anything_goes\"]") + val graph = compiler.compile(plan, "unvalidated-workflow") + assertEquals(2, graph.stages.size) + } } From a96c05e22845ad334e11b3d5d848db6a382f1812 Mon Sep 17 00:00:00 2001 From: kami Date: Wed, 24 Jun 2026 16:16:03 +0000 Subject: [PATCH 088/107] test(tasks): pin the task-tool wiring in the shipped workflows Nothing asserted that the example workflows actually grant the task tools, so a future edit could silently drop one and stay green. Pin allowed_tools per stage for role_pipeline (analyst/implementer/reviewer), freestyle_planning (analyst), and the shipped review_loop (implement/review). The existing review_loop test loaded an inline, now-stale toml rather than the shipped file, so add one that loads the real file. Co-Authored-By: Claude Opus 4.8 --- .../workflow/FreestylePlanningWorkflowTest.kt | 6 ++++ .../workflow/ReviewLoopWorkflowTest.kt | 31 +++++++++++++++++++ .../workflow/RolePipelineWorkflowTest.kt | 24 ++++++++++++++ 3 files changed, 61 insertions(+) diff --git a/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/FreestylePlanningWorkflowTest.kt b/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/FreestylePlanningWorkflowTest.kt index bd238835..4dd07652 100644 --- a/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/FreestylePlanningWorkflowTest.kt +++ b/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/FreestylePlanningWorkflowTest.kt @@ -69,5 +69,11 @@ class FreestylePlanningWorkflowTest { assertTrue(architectToDone.condition is ArtifactValidated) assertEquals(2, graph.transitions.size) + + // analyst frames the work: search + open a task (the architect threads it into the plan). + assertEquals( + setOf("file_read", "ShellTool", "task_search", "task_context", "task_create"), + graph.stages[StageId("analyst")]!!.allowedTools, + ) } } diff --git a/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/ReviewLoopWorkflowTest.kt b/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/ReviewLoopWorkflowTest.kt index 0172cc3e..b2d4c577 100644 --- a/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/ReviewLoopWorkflowTest.kt +++ b/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/ReviewLoopWorkflowTest.kt @@ -7,8 +7,11 @@ import com.correx.core.artifacts.kind.JsonSchemaProperty import com.correx.core.events.types.StageId import com.correx.core.transitions.conditions.ArtifactFieldEquals import com.correx.core.transitions.conditions.FieldOperator +import org.junit.jupiter.api.Assumptions.assumeTrue import org.junit.jupiter.api.Test import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.exists import kotlin.io.path.writeText import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -93,4 +96,32 @@ class ReviewLoopWorkflowTest { val changes = graph.transitions.first { it.to == StageId("implement") }.condition as ArtifactFieldEquals assertEquals(FieldOperator.NEQ, changes.operator) } + + private fun repoFile(rel: String): Path? { + var dir: Path? = Path.of(System.getProperty("user.dir")).toAbsolutePath() + while (dir != null) { + val candidate = dir.resolve(rel) + if (candidate.exists()) return candidate + dir = dir.parent + } + return null + } + + @Test + fun `shipped review_loop grants the task tools to both stages`() { + val path = repoFile("examples/workflows/review_loop.toml") + assumeTrue(path != null, "review_loop.toml not found from ${System.getProperty("user.dir")}") + val graph = loader.load(path!!) + + // implement claims/submits and may create a task, alongside its file write. + assertEquals( + setOf("file_write", "task_create", "task_update", "task_context", "task_search"), + graph.stages[StageId("implement")]!!.allowedTools, + ) + // review reads the task and completes it on an approved verdict. + assertEquals( + setOf("task_context", "task_update"), + graph.stages[StageId("review")]!!.allowedTools, + ) + } } diff --git a/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/RolePipelineWorkflowTest.kt b/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/RolePipelineWorkflowTest.kt index f0eead46..cab71f82 100644 --- a/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/RolePipelineWorkflowTest.kt +++ b/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/RolePipelineWorkflowTest.kt @@ -81,4 +81,28 @@ class RolePipelineWorkflowTest { .condition as ArtifactFieldEquals assertEquals(FieldOperator.NEQ, loop.operator) } + + @Test + fun `task tools are granted to the right stages`() { + val path = repoFile("examples/workflows/role_pipeline.toml") + assumeTrue(path != null, "role_pipeline.toml not found from ${System.getProperty("user.dir")}") + val graph = TomlWorkflowLoader(registry).load(path!!) + + // analyst opens + searches (read-only on files); creation is approval-gated (T2). + assertEquals( + setOf("file_read", "ShellTool", "task_search", "task_context", "task_create"), + graph.stages[StageId("analyst")]!!.allowedTools, + ) + // implementer owns the lifecycle: claim/submit + the full file set. + assertEquals( + setOf("file_read", "file_write", "file_edit", "ShellTool") + + setOf("task_create", "task_update", "task_context", "task_search"), + graph.stages[StageId("implementer")]!!.allowedTools, + ) + // reviewer reads the task and completes it on an approved verdict. + assertEquals( + setOf("task_context", "task_update"), + graph.stages[StageId("reviewer")]!!.allowedTools, + ) + } } From bce62c080c2f1ff73da738412da5d752aa8ef79e Mon Sep 17 00:00:00 2001 From: kami Date: Wed, 24 Jun 2026 16:37:50 +0000 Subject: [PATCH 089/107] feat(tasks): per-task history / audit timeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces a task's lifecycle straight from the event log (the source of truth) so you can see what actually happened to it — claim/submit/complete, notes, links, git-driven status — rather than only its current state. Useful for debugging a live agent run, where current state alone doesn't say how it got there. - TaskHistory.render: pure timeline renderer (split content/lifecycle to stay under the complexity cap, mirroring DefaultTaskReducer). - TaskService.history(id): the task's own events, in order; survives soft-delete (the deletion is part of the trail), empty for an id that never existed. - GET /tasks/{id}/history (200 text timeline, 404 when the id never existed) and the `correx task history ` CLI command. Co-Authored-By: Claude Opus 4.8 --- .../correx/apps/cli/commands/TaskCommand.kt | 14 ++++ .../correx/apps/server/routes/TaskRoutes.kt | 9 +++ .../apps/server/routes/TaskRoutesTest.kt | 22 +++++ .../com/correx/core/tasks/TaskHistory.kt | 64 +++++++++++++++ .../com/correx/core/tasks/TaskService.kt | 10 +++ .../com/correx/core/tasks/TaskHistoryTest.kt | 81 +++++++++++++++++++ .../com/correx/core/tasks/TaskServiceTest.kt | 26 ++++++ 7 files changed, 226 insertions(+) create mode 100644 core/tasks/src/main/kotlin/com/correx/core/tasks/TaskHistory.kt create mode 100644 core/tasks/src/test/kotlin/com/correx/core/tasks/TaskHistoryTest.kt diff --git a/apps/cli/src/main/kotlin/com/correx/apps/cli/commands/TaskCommand.kt b/apps/cli/src/main/kotlin/com/correx/apps/cli/commands/TaskCommand.kt index 3a75d00c..32dd5a21 100644 --- a/apps/cli/src/main/kotlin/com/correx/apps/cli/commands/TaskCommand.kt +++ b/apps/cli/src/main/kotlin/com/correx/apps/cli/commands/TaskCommand.kt @@ -91,6 +91,7 @@ class TaskCommand : CliktCommand(name = "task") { TaskListCommand(), TaskSearchCommand(), TaskShowCommand(), + TaskHistoryCommand(), TaskCreateCommand(), TaskClaimCommand(), TaskCompleteCommand(), @@ -160,6 +161,19 @@ class TaskShowCommand : TaskHttpCommand("show") { } } +class TaskHistoryCommand : TaskHttpCommand("history") { + private val id by argument("TASK_ID") + + override fun run() = http { client -> + val resp = client.get("${baseUrl()}/tasks/$id/history") + if (resp.status == HttpStatusCode.NotFound) { + System.err.println("No such task: $id") + } else { + print(resp.bodyAsText()) + } + } +} + class TaskCreateCommand : TaskHttpCommand("create") { private val project by option("--project", help = "Project key, e.g. 'auth'").required() private val title by option("--title").required() diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt b/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt index b690000b..2a6da8c6 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt @@ -11,6 +11,7 @@ import com.correx.core.events.types.TaskNoteAuthor import com.correx.core.events.types.TaskTargetKind import com.correx.core.tasks.Task import com.correx.core.tasks.TaskContextAssembler +import com.correx.core.tasks.TaskHistory import com.correx.core.tasks.TaskMarkdown import com.correx.core.tasks.TaskSearch import com.correx.core.tasks.TaskService @@ -200,6 +201,14 @@ private fun Route.taskCrudRoutes(service: TaskService, assembler: TaskContextAss val bundle = assembler.assemble(id) ?: return@get call.respond(HttpStatusCode.NotFound, "Task not found") call.respond(bundle) } + // Lifecycle audit trail from the event log; visible even after a soft-delete. Empty history + // means the task id never existed. + get("/history") { + val id = taskId() ?: return@get call.respond(HttpStatusCode.BadRequest, "Missing task id") + val events = service.history(id) + if (events.isEmpty()) return@get call.respond(HttpStatusCode.NotFound, "Task not found") + call.respondText(TaskHistory.render(events), ContentType.Text.Plain) + } } private fun Route.taskTransitionRoutes(service: TaskService) { diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/routes/TaskRoutesTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/routes/TaskRoutesTest.kt index beee6378..75062afb 100644 --- a/apps/server/src/test/kotlin/com/correx/apps/server/routes/TaskRoutesTest.kt +++ b/apps/server/src/test/kotlin/com/correx/apps/server/routes/TaskRoutesTest.kt @@ -256,6 +256,28 @@ class TaskRoutesTest { } } + @Test + fun `GET history renders the lifecycle timeline`(@TempDir tempDir: Path) { + testApplication { + application { configureServer(buildModule(tempDir)) } + val client = createClient {} + client.createTask() // demo-1 + client.post("/tasks/demo-1/claim") { + contentType(ContentType.Application.Json) + setBody("""{"claimant":"claude-opus"}""") + } + + val res = client.get("/tasks/demo-1/history") + assertEquals(HttpStatusCode.OK, res.status) + val timeline = res.bodyAsText() + assertTrue(timeline.contains("created demo-1")) + assertTrue(timeline.contains("claimed by claude-opus")) + + // An id that never existed has no history → 404. + assertEquals(HttpStatusCode.NotFound, client.get("/tasks/demo-999/history").status) + } + } + @Test fun `bad requests are rejected`(@TempDir tempDir: Path) { testApplication { diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskHistory.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskHistory.kt new file mode 100644 index 00000000..ff4a0a64 --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskHistory.kt @@ -0,0 +1,64 @@ +package com.correx.core.tasks + +import com.correx.core.events.events.StoredEvent +import com.correx.core.events.events.TaskAcceptanceCriteriaSetEvent +import com.correx.core.events.events.TaskAffectedPathsSetEvent +import com.correx.core.events.events.TaskBlockedEvent +import com.correx.core.events.events.TaskCancelledEvent +import com.correx.core.events.events.TaskClaimedEvent +import com.correx.core.events.events.TaskCompletedEvent +import com.correx.core.events.events.TaskCreatedEvent +import com.correx.core.events.events.TaskDeletedEvent +import com.correx.core.events.events.TaskEditedEvent +import com.correx.core.events.events.TaskEvent +import com.correx.core.events.events.TaskLinkedEvent +import com.correx.core.events.events.TaskNoteAddedEvent +import com.correx.core.events.events.TaskReleasedEvent +import com.correx.core.events.events.TaskReopenedEvent +import com.correx.core.events.events.TaskSubmittedForReviewEvent +import com.correx.core.events.events.TaskUnblockedEvent +import com.correx.core.events.events.TaskUnlinkedEvent + +/** + * Renders a task's event log as a human-readable lifecycle timeline. The log is the source of + * truth, so this is the audit trail straight from the facts — one line per event, in order, + * ` `. Useful for seeing what an agent actually did to a task + * (claim/submit/complete, notes, links, git-driven status) rather than just its current state. + */ +object TaskHistory { + + fun render(events: List): String { + val lines = events.mapNotNull { e -> + val payload = e.payload as? TaskEvent ?: return@mapNotNull null + "${e.metadata.timestamp} ${describe(payload)}" + } + return if (lines.isEmpty()) "no history" else lines.joinToString("\n") + } + + private fun describe(p: TaskEvent): String = + describeContent(p) ?: describeLifecycle(p) ?: (p::class.simpleName ?: "event") + + private fun describeContent(p: TaskEvent): String? = when (p) { + is TaskCreatedEvent -> "created ${p.key}: ${p.title}" + is TaskEditedEvent -> "edited" + is TaskAcceptanceCriteriaSetEvent -> "acceptance criteria set (${p.criteria.size})" + is TaskAffectedPathsSetEvent -> "affected paths set (${p.paths.size})" + is TaskLinkedEvent -> "linked ${p.type} -> ${p.targetId} (${p.targetKind})" + is TaskUnlinkedEvent -> "unlinked ${p.type} -> ${p.targetId}" + is TaskNoteAddedEvent -> "note [${p.author}] ${p.body}" + is TaskDeletedEvent -> "deleted" + else -> null + } + + private fun describeLifecycle(p: TaskEvent): String? = when (p) { + is TaskClaimedEvent -> "claimed by ${p.claimant}" + is TaskReleasedEvent -> "released" + is TaskBlockedEvent -> "blocked: ${p.reason}" + is TaskUnblockedEvent -> "unblocked" + is TaskSubmittedForReviewEvent -> "submitted for review" + is TaskCompletedEvent -> "completed" + is TaskReopenedEvent -> "reopened" + is TaskCancelledEvent -> "cancelled" + (p.reason?.let { ": $it" } ?: "") + else -> null + } +} diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskService.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskService.kt index 4aee0d68..9413b057 100644 --- a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskService.kt +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskService.kt @@ -3,6 +3,7 @@ package com.correx.core.tasks import com.correx.core.events.events.EventMetadata import com.correx.core.events.events.EventPayload import com.correx.core.events.events.NewEvent +import com.correx.core.events.events.StoredEvent import com.correx.core.events.events.TaskAcceptanceCriteriaSetEvent import com.correx.core.events.events.TaskAffectedPathsSetEvent import com.correx.core.events.events.TaskBlockedEvent @@ -12,6 +13,7 @@ import com.correx.core.events.events.TaskCompletedEvent import com.correx.core.events.events.TaskCreatedEvent import com.correx.core.events.events.TaskDeletedEvent import com.correx.core.events.events.TaskEditedEvent +import com.correx.core.events.events.TaskEvent import com.correx.core.events.events.TaskLinkedEvent import com.correx.core.events.events.TaskNoteAddedEvent import com.correx.core.events.events.TaskReleasedEvent @@ -70,6 +72,14 @@ class TaskService( return if (state.deleted) null else Task(taskId, state) } + /** + * A task's own events in order — the lifecycle audit trail straight from the log (survives + * soft-delete, unlike [getTask]). Empty when the task never existed. Render with [TaskHistory]. + */ + fun history(taskId: TaskId): List = + eventStore.read(TaskStreams.forProject(projectOf(taskId))) + .filter { (it.payload as? TaskEvent)?.taskId == taskId } + /** Ranked text search over one project (when given) or the whole cross-project board. */ fun search(query: String, projectId: ProjectId? = null): List = TaskSearch.search(projectId?.let { list(it) } ?: listAll(), query) diff --git a/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskHistoryTest.kt b/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskHistoryTest.kt new file mode 100644 index 00000000..472fd313 --- /dev/null +++ b/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskHistoryTest.kt @@ -0,0 +1,81 @@ +package com.correx.core.tasks + +import com.correx.core.events.events.EventMetadata +import com.correx.core.events.events.EventPayload +import com.correx.core.events.events.StoredEvent +import com.correx.core.events.events.TaskCancelledEvent +import com.correx.core.events.events.TaskClaimedEvent +import com.correx.core.events.events.TaskCompletedEvent +import com.correx.core.events.events.TaskCreatedEvent +import com.correx.core.events.events.TaskNoteAddedEvent +import com.correx.core.events.events.TaskSubmittedForReviewEvent +import com.correx.core.events.types.EventId +import com.correx.core.events.types.ProjectId +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.TaskId +import com.correx.core.events.types.TaskNoteAuthor +import kotlinx.datetime.Instant +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class TaskHistoryTest { + + private val taskId = TaskId("auth-1") + + private fun stored(payload: EventPayload, seq: Long) = StoredEvent( + metadata = EventMetadata( + eventId = EventId("e-$seq"), + sessionId = SessionId("tasks:auth"), + timestamp = Instant.parse("2026-01-0${seq}T00:00:00Z"), + schemaVersion = 1, + causationId = null, + correlationId = null, + ), + sequence = seq, + sessionSequence = seq, + payload = payload, + ) + + private fun created() = TaskCreatedEvent( + taskId = taskId, + projectId = ProjectId("auth"), + key = "auth-1", + title = "Add JWT refresh", + goal = "users stay authenticated", + ) + + @Test + fun `renders the lifecycle as a timeline in order`() { + val events = listOf( + stored(created(), 1), + stored(TaskClaimedEvent(taskId, "claude-opus"), 2), + stored(TaskNoteAddedEvent(taskId, TaskNoteAuthor.AGENT, "migration retried"), 3), + stored(TaskSubmittedForReviewEvent(taskId), 4), + stored(TaskCompletedEvent(taskId), 5), + ) + + val lines = TaskHistory.render(events).lines() + + assertEquals(5, lines.size) + assertTrue(lines[0].contains("2026-01-01T00:00:00Z"), lines[0]) + assertTrue(lines[0].contains("created auth-1: Add JWT refresh"), lines[0]) + assertTrue(lines[1].contains("claimed by claude-opus"), lines[1]) + assertTrue(lines[2].contains("note [AGENT] migration retried"), lines[2]) + assertTrue(lines[3].contains("submitted for review"), lines[3]) + assertTrue(lines[4].contains("completed"), lines[4]) + } + + @Test + fun `cancellation reason is included`() { + val lines = TaskHistory.render( + listOf(stored(created(), 1), stored(TaskCancelledEvent(taskId, "superseded"), 2)), + ).lines() + assertTrue(lines[1].contains("cancelled: superseded"), lines[1]) + } + + @Test + fun `empty event list renders as no history`() { + assertEquals("no history", TaskHistory.render(emptyList())) + } +} diff --git a/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskServiceTest.kt b/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskServiceTest.kt index 469692ef..b3a8bb62 100644 --- a/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskServiceTest.kt +++ b/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskServiceTest.kt @@ -89,4 +89,30 @@ class TaskServiceTest { // count() is creation-based, so the deleted id is never reused. assertEquals("auth-2", third.taskId.value) } + + @Test + fun `history returns the task's own events in order`() = runBlocking { + val id = service.createTask(project, "Add JWT refresh", "auth").taskId + service.claim(id, "claude-opus") + service.submitForReview(id) + service.complete(id) + // A second task in the same stream must not bleed into this one's history. + service.createTask(project, "unrelated", "g") + + val lines = TaskHistory.render(service.history(id)).lines() + assertEquals(4, lines.size) + assertTrue(lines.first().contains("created")) + assertTrue(lines.last().contains("completed")) + } + + @Test + fun `history survives soft-delete but is empty for an unknown task`() = runBlocking { + val id = service.createTask(project, "throwaway", "oops").taskId + service.delete(id) + + // getTask hides a deleted task, but its audit trail (incl. the deletion) remains. + assertNull(service.getTask(id)) + assertTrue(service.history(id).isNotEmpty()) + assertTrue(service.history(TaskId("auth-999")).isEmpty()) + } } From e77b2960f9e5c3d4c201bbe21af54dbf0f7e3920 Mon Sep 17 00:00:00 2001 From: kami Date: Wed, 24 Jun 2026 17:02:03 +0000 Subject: [PATCH 090/107] feat(tasks): dependency-aware work graph (blockers, ready, blocking) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DEPENDS_ON/BLOCKS link types were inert metadata — nothing reasoned about them. TaskGraph turns them into answerable questions: what a task waits on (its DEPENDS_ON targets plus any task that BLOCKS it), whether it is ready (TODO with no unmet blocker — a terminal dependency stops blocking), and what finishing it would unblock. Readiness surfaces workable tasks to claim; it never assigns (honouring the rejected /tasks/next scheduler). Surfaced across the stack: - TaskService.ready / blockers / blocking. - Context bundle: a `blocked` flag + `blocked_by` list, rendered as a prominent "BLOCKED — waiting on ..." line, so an agent calling task_context learns it should wait before charging at the task. - GET /tasks?ready=true, GET /tasks/{id}/blockers; correx task ready / blockers. Cross-project dependencies are scoped to a project for now (documented in TaskGraph). Tests cover both link directions, terminal-dependency clearing, the ready filter, the reverse blocking direction, the bundle flag, and REST. Co-Authored-By: Claude Opus 4.8 --- .../correx/apps/cli/commands/TaskCommand.kt | 26 ++++++++ .../correx/apps/server/routes/TaskRoutes.kt | 12 ++++ .../apps/server/routes/TaskRoutesTest.kt | 29 ++++++++ .../correx/core/tasks/TaskContextAssembler.kt | 26 ++++++++ .../correx/core/tasks/TaskContextBundle.kt | 6 ++ .../kotlin/com/correx/core/tasks/TaskGraph.kt | 54 +++++++++++++++ .../com/correx/core/tasks/TaskService.kt | 20 ++++++ .../core/tasks/TaskContextAssemblerTest.kt | 21 ++++++ .../com/correx/core/tasks/TaskGraphTest.kt | 66 +++++++++++++++++++ .../com/correx/core/tasks/TaskServiceTest.kt | 16 +++++ 10 files changed, 276 insertions(+) create mode 100644 core/tasks/src/main/kotlin/com/correx/core/tasks/TaskGraph.kt create mode 100644 core/tasks/src/test/kotlin/com/correx/core/tasks/TaskGraphTest.kt diff --git a/apps/cli/src/main/kotlin/com/correx/apps/cli/commands/TaskCommand.kt b/apps/cli/src/main/kotlin/com/correx/apps/cli/commands/TaskCommand.kt index 32dd5a21..a29521ce 100644 --- a/apps/cli/src/main/kotlin/com/correx/apps/cli/commands/TaskCommand.kt +++ b/apps/cli/src/main/kotlin/com/correx/apps/cli/commands/TaskCommand.kt @@ -89,8 +89,10 @@ class TaskCommand : CliktCommand(name = "task") { init { subcommands( TaskListCommand(), + TaskReadyCommand(), TaskSearchCommand(), TaskShowCommand(), + TaskBlockersCommand(), TaskHistoryCommand(), TaskCreateCommand(), TaskClaimCommand(), @@ -133,6 +135,30 @@ class TaskListCommand : TaskHttpCommand("list") { } } +class TaskReadyCommand : TaskHttpCommand("ready") { + private val project by option("--project", help = "Limit to a project") + + override fun run() = http { client -> + val query = project?.let { "&project=${enc(it)}" }.orEmpty() + val raw = client.get("${baseUrl()}/tasks?ready=true$query").bodyAsText() + if (jsonOut()) println(raw) else println(renderTaskList(taskJson.decodeFromString(raw))) + } +} + +class TaskBlockersCommand : TaskHttpCommand("blockers") { + private val id by argument("TASK_ID") + + override fun run() = http { client -> + val resp = client.get("${baseUrl()}/tasks/$id/blockers") + if (resp.status == HttpStatusCode.NotFound) { + System.err.println("No such task: $id") + } else { + val raw = resp.bodyAsText() + if (jsonOut()) println(raw) else println(renderTaskList(taskJson.decodeFromString(raw))) + } + } +} + class TaskSearchCommand : TaskHttpCommand("search") { private val query by argument("QUERY", help = "Search text (quote multi-word queries)") private val project by option("--project", help = "Limit to a project") diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt b/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt index 2a6da8c6..81a0d994 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt @@ -126,6 +126,12 @@ fun Route.taskRoutes(module: ServerModule) { private fun Route.taskCollectionRoutes(service: TaskService) { get { + // ?ready=true short-circuits to the dependency-aware workable set (TODO, no unmet blockers). + if (call.request.queryParameters["ready"]?.toBoolean() == true) { + val readyProject = call.request.queryParameters["project"] + val workable = service.ready(readyProject?.let { ProjectId(it) }) + return@get call.respond(workable.map { it.toResponse() }) + } val statusRaw = call.request.queryParameters["status"] val statusFilter = statusRaw?.let { parseEnum(it) ?: return@get call.respond(HttpStatusCode.BadRequest, "Invalid status: $it") @@ -209,6 +215,12 @@ private fun Route.taskCrudRoutes(service: TaskService, assembler: TaskContextAss if (events.isEmpty()) return@get call.respond(HttpStatusCode.NotFound, "Task not found") call.respondText(TaskHistory.render(events), ContentType.Text.Plain) } + // Unfinished tasks this one is waiting on (its dependencies + inbound blocks). + get("/blockers") { + val id = taskId() ?: return@get call.respond(HttpStatusCode.BadRequest, "Missing task id") + if (service.getTask(id) == null) return@get call.respond(HttpStatusCode.NotFound, "Task not found") + call.respond(service.blockers(id).map { it.toResponse() }) + } } private fun Route.taskTransitionRoutes(service: TaskService) { diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/routes/TaskRoutesTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/routes/TaskRoutesTest.kt index 75062afb..a56f713e 100644 --- a/apps/server/src/test/kotlin/com/correx/apps/server/routes/TaskRoutesTest.kt +++ b/apps/server/src/test/kotlin/com/correx/apps/server/routes/TaskRoutesTest.kt @@ -256,6 +256,35 @@ class TaskRoutesTest { } } + @Test + fun `ready returns unblocked TODO tasks and blockers explains a wait`(@TempDir tempDir: Path) { + testApplication { + application { configureServer(buildModule(tempDir)) } + val client = createClient {} + client.createTask() // demo-1, TODO + client.post("/tasks") { + contentType(ContentType.Application.Json) + setBody("""{"project":"demo","title":"second","goal":"g"}""") + } + // demo-2 depends on demo-1 (id infers to a TASK target). + client.post("/tasks/demo-2/links") { + contentType(ContentType.Application.Json) + setBody("""{"targetId":"demo-1","type":"DEPENDS_ON"}""") + } + + val ready = client.get("/tasks?ready=true") + assertEquals(HttpStatusCode.OK, ready.status) + val rows = testJson.parseToJsonElement(ready.bodyAsText()) as JsonArray + assertEquals(1, rows.size) + assertEquals("demo-1", (rows[0] as JsonObject)["id"]!!.jsonPrimitive.content) + + val blockers = client.get("/tasks/demo-2/blockers") + assertEquals(HttpStatusCode.OK, blockers.status) + val blk = testJson.parseToJsonElement(blockers.bodyAsText()) as JsonArray + assertEquals("demo-1", (blk.single() as JsonObject)["id"]!!.jsonPrimitive.content) + } + } + @Test fun `GET history renders the lifecycle timeline`(@TempDir tempDir: Path) { testApplication { diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskContextAssembler.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskContextAssembler.kt index b7bd3b0b..fd8eaeab 100644 --- a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskContextAssembler.kt +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskContextAssembler.kt @@ -1,6 +1,7 @@ package com.correx.core.tasks import com.correx.core.events.types.TaskId +import com.correx.core.events.types.TaskLinkType import com.correx.core.events.types.TaskTargetKind /** @@ -38,6 +39,8 @@ class TaskContextAssembler( } } + val blockedBy = computeBlockedBy(taskId, state) + return TaskContextBundle( id = taskId.value, key = state.key, @@ -46,6 +49,8 @@ class TaskContextAssembler( goal = state.goal, acceptanceCriteria = state.acceptanceCriteria, relevantFiles = state.affectedPaths, + blocked = blockedBy.isNotEmpty(), + blockedBy = blockedBy, dependencies = resolved.dependencies, documents = resolved.documents, artifacts = resolved.artifacts, @@ -124,6 +129,27 @@ class TaskContextAssembler( } } + /** + * The task's unfinished dependency blockers, resolved over its project board (see [TaskGraph]), + * each labelled by the direction of the edge. Empty when the project board can't be read. + */ + private fun computeBlockedBy(taskId: TaskId, state: TaskState): List { + val board = state.projectId?.let { runCatching { service.list(it) }.getOrNull() } ?: return emptyList() + return TaskGraph.unmetBlockers(Task(taskId, state), board).map { b -> + val viaDependsOn = state.links.any { + it.targetKind == TaskTargetKind.TASK && + it.type == TaskLinkType.DEPENDS_ON && + it.targetId == b.taskId.value + } + RelatedTask( + id = b.taskId.value, + status = b.state.status.name, + title = b.state.title, + link = if (viaDependsOn) "DEPENDS_ON" else "BLOCKS", + ) + } + } + private suspend fun retrieveKnowledge(state: TaskState): List { val active = retriever ?: return emptyList() val query = listOfNotNull(state.title, state.goal).joinToString(" ").trim() diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskContextBundle.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskContextBundle.kt index db2090e6..9b809bb4 100644 --- a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskContextBundle.kt +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskContextBundle.kt @@ -16,6 +16,10 @@ data class TaskContextBundle( val goal: String?, val acceptanceCriteria: List, val relevantFiles: List, + // Dependency-derived readiness (distinct from an explicit BLOCKED status): blockedBy holds the + // unfinished tasks this one is waiting on, so an agent calling task_context knows to wait. + val blocked: Boolean = false, + val blockedBy: List = emptyList(), val dependencies: List, val documents: List, val artifacts: List = emptyList(), @@ -28,6 +32,8 @@ data class TaskContextBundle( fun render(): String = buildString { appendLine("task $id [$status] ${title.orEmpty()}".trimEnd()) goal?.let { appendLine("goal: $it") } + if (blocked) appendLine("BLOCKED — waiting on ${blockedBy.size} unfinished dependency(ies):") + appendList("blocked_by", blockedBy) { " - ${it.id} [${it.status}] ${it.title.orEmpty()} (${it.link})".trimEnd() } appendList("acceptance_criteria", acceptanceCriteria) { " - $it" } if (relevantFiles.isNotEmpty()) appendLine("relevant_files: ${relevantFiles.joinToString(", ")}") appendList("dependencies", dependencies) { " - ${it.id} [${it.status}] ${it.title.orEmpty()} (${it.link})".trimEnd() } diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskGraph.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskGraph.kt new file mode 100644 index 00000000..2a2e05b6 --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskGraph.kt @@ -0,0 +1,54 @@ +package com.correx.core.tasks + +import com.correx.core.events.types.TaskLinkType +import com.correx.core.events.types.TaskTargetKind + +/** + * Dependency reasoning over the task work graph — turns the `DEPENDS_ON`/`BLOCKS` link types from + * inert metadata into answerable questions: what is a task waiting on, what is it ready to be + * worked, and what does finishing it unblock. + * + * Two directions express the same edge, so both count: a `DEPENDS_ON B` link on A means A waits on + * B; a `BLOCKS A` link on B means the same. A blocker is "unmet" until it reaches a terminal + * status ([FINISHED]); a CANCELLED dependency no longer blocks. All reasoning is over the supplied + * board, so callers scope it (one project, or the whole cross-project set); a link to a task absent + * from the board is treated as already-resolved rather than an unknown blocker. + */ +object TaskGraph { + + private val FINISHED = setOf(TaskStatus.DONE, TaskStatus.CANCELLED) + + /** Tasks that must finish before [task] proceeds: its `DEPENDS_ON` targets plus tasks that `BLOCKS` it. */ + fun blockers(task: Task, board: Collection): List { + val byId = board.associateBy { it.taskId.value } + val dependsOn = task.linkTargets(TaskLinkType.DEPENDS_ON).mapNotNull { byId[it] } + val inbound = board.filter { it.hasTaskLink(TaskLinkType.BLOCKS, task.taskId.value) } + return (dependsOn + inbound).distinctBy { it.taskId.value } + } + + /** The blockers that have not finished — the concrete reason [task] cannot start yet. */ + fun unmetBlockers(task: Task, board: Collection): List = + blockers(task, board).filter { it.state.status !in FINISHED } + + /** True when [task] is TODO and has no unmet blockers — workable now (to be claimed, not assigned). */ + fun isReady(task: Task, board: Collection): Boolean = + task.state.status == TaskStatus.TODO && unmetBlockers(task, board).isEmpty() + + /** Every workable task on the board, in board order. */ + fun ready(board: Collection): List = + board.filter { isReady(it, board) } + + /** Tasks [task] is holding up: its own `BLOCKS` targets plus tasks that `DEPENDS_ON` it. */ + fun blocking(task: Task, board: Collection): List { + val byId = board.associateBy { it.taskId.value } + val downstream = task.linkTargets(TaskLinkType.BLOCKS).mapNotNull { byId[it] } + val dependents = board.filter { it.hasTaskLink(TaskLinkType.DEPENDS_ON, task.taskId.value) } + return (downstream + dependents).distinctBy { it.taskId.value } + } + + private fun Task.linkTargets(type: TaskLinkType): List = + state.links.filter { it.targetKind == TaskTargetKind.TASK && it.type == type }.map { it.targetId } + + private fun Task.hasTaskLink(type: TaskLinkType, targetId: String): Boolean = + state.links.any { it.targetKind == TaskTargetKind.TASK && it.type == type && it.targetId == targetId } +} diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskService.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskService.kt index 9413b057..2f83006b 100644 --- a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskService.kt +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskService.kt @@ -84,6 +84,26 @@ class TaskService( fun search(query: String, projectId: ProjectId? = null): List = TaskSearch.search(projectId?.let { list(it) } ?: listAll(), query) + /** + * Tasks ready to be picked up: TODO with every dependency satisfied (see [TaskGraph]). Surfaces + * workable tasks for an agent or human to claim — it never assigns. Scoped to one project when + * given, else the whole cross-project board. + */ + fun ready(projectId: ProjectId? = null): List = + TaskGraph.ready(projectId?.let { list(it) } ?: listAll()) + + /** Unfinished tasks that must complete before [taskId] can proceed (resolved within its project). */ + fun blockers(taskId: TaskId): List { + val task = getTask(taskId) ?: return emptyList() + return TaskGraph.unmetBlockers(task, list(projectOf(taskId))) + } + + /** Tasks that [taskId] is holding up — what completing it would unblock (within its project). */ + fun blocking(taskId: TaskId): List { + val task = getTask(taskId) ?: return emptyList() + return TaskGraph.blocking(task, list(projectOf(taskId))) + } + suspend fun createTask( projectId: ProjectId, title: String, diff --git a/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskContextAssemblerTest.kt b/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskContextAssemblerTest.kt index 13f623f1..03e45880 100644 --- a/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskContextAssemblerTest.kt +++ b/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskContextAssemblerTest.kt @@ -53,6 +53,27 @@ class TaskContextAssemblerTest { assertEquals("kickoff", bundle.notes.single().body) } + @Test + fun `bundle flags an unfinished dependency as a blocker and clears it when done`() = runBlocking { + val dep = service.createTask(project, "Design auth model", "model") // auth-1 + service.claim(dep.taskId, "claude-opus") // auth-1 IN_PROGRESS + val task = service.createTask(project, "Implement JWT refresh", "auth") // auth-2 + service.link(task.taskId, dep.taskId.value, TaskLinkType.DEPENDS_ON, TaskTargetKind.TASK) + + val blocked = assembler.assemble(task.taskId)!! + assertTrue(blocked.blocked) + assertEquals("auth-1", blocked.blockedBy.single().id) + assertEquals("IN_PROGRESS", blocked.blockedBy.single().status) + assertEquals("DEPENDS_ON", blocked.blockedBy.single().link) + assertTrue(blocked.render().contains("BLOCKED")) + + service.submitForReview(dep.taskId) + service.complete(dep.taskId) + val cleared = assembler.assemble(task.taskId)!! + assertTrue(!cleared.blocked) + assertTrue(cleared.blockedBy.isEmpty()) + } + @Test fun `render produces a compact labelled bundle`() = runBlocking { val task = service.createTask(project, "JWT refresh", "stay authed", listOf("rotates")) diff --git a/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskGraphTest.kt b/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskGraphTest.kt new file mode 100644 index 00000000..320628b2 --- /dev/null +++ b/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskGraphTest.kt @@ -0,0 +1,66 @@ +package com.correx.core.tasks + +import com.correx.core.events.types.ProjectId +import com.correx.core.events.types.TaskId +import com.correx.core.events.types.TaskLinkType +import com.correx.core.events.types.TaskTargetKind +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 + +class TaskGraphTest { + + private fun task(id: String, status: TaskStatus = TaskStatus.TODO, links: List = emptyList()) = + Task(TaskId(id), TaskState(status = status, key = id, projectId = ProjectId("p"), title = id, links = links)) + + private fun dependsOn(target: String) = TaskLink(target, TaskLinkType.DEPENDS_ON, TaskTargetKind.TASK) + private fun blocks(target: String) = TaskLink(target, TaskLinkType.BLOCKS, TaskTargetKind.TASK) + + @Test + fun `an unfinished depends_on target blocks the task`() { + val a = task("p-1", links = listOf(dependsOn("p-2"))) + val b = task("p-2", status = TaskStatus.IN_PROGRESS) + val board = listOf(a, b) + + assertEquals(listOf("p-2"), TaskGraph.unmetBlockers(a, board).map { it.taskId.value }) + assertFalse(TaskGraph.isReady(a, board)) + } + + @Test + fun `a finished dependency no longer blocks`() { + val a = task("p-1", links = listOf(dependsOn("p-2"))) + val done = task("p-2", status = TaskStatus.DONE) + val board = listOf(a, done) + + assertTrue(TaskGraph.unmetBlockers(a, board).isEmpty()) + assertTrue(TaskGraph.isReady(a, board)) + } + + @Test + fun `an inbound BLOCKS edge blocks the target`() { + val a = task("p-1") + val blocker = task("p-2", status = TaskStatus.IN_PROGRESS, links = listOf(blocks("p-1"))) + val board = listOf(a, blocker) + + assertEquals(listOf("p-2"), TaskGraph.unmetBlockers(a, board).map { it.taskId.value }) + } + + @Test + fun `ready lists only unblocked TODO tasks`() { + val a = task("p-1", links = listOf(dependsOn("p-2"))) // blocked + val b = task("p-2", status = TaskStatus.IN_PROGRESS) // not TODO + val c = task("p-3") // unblocked TODO → ready + + assertEquals(listOf("p-3"), TaskGraph.ready(listOf(a, b, c)).map { it.taskId.value }) + } + + @Test + fun `blocking lists what completing a task unblocks`() { + val dependent = task("p-1", links = listOf(dependsOn("p-2"))) + val target = task("p-2") + val board = listOf(dependent, target) + + assertEquals(listOf("p-1"), TaskGraph.blocking(target, board).map { it.taskId.value }) + } +} diff --git a/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskServiceTest.kt b/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskServiceTest.kt index b3a8bb62..f9c19936 100644 --- a/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskServiceTest.kt +++ b/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskServiceTest.kt @@ -105,6 +105,22 @@ class TaskServiceTest { assertTrue(lines.last().contains("completed")) } + @Test + fun `ready surfaces unblocked work and blockers explain the wait`() = runBlocking { + val a = service.createTask(project, "A", "g").taskId // auth-1 + val b = service.createTask(project, "B", "g").taskId // auth-2 depends on auth-1 + service.link(b, a.value, TaskLinkType.DEPENDS_ON, TaskTargetKind.TASK) + + assertEquals(setOf("auth-1"), service.ready(project).map { it.taskId.value }.toSet()) + assertEquals(listOf("auth-1"), service.blockers(b).map { it.taskId.value }) + assertEquals(listOf("auth-2"), service.blocking(a).map { it.taskId.value }) + + // Finishing auth-1 unblocks auth-2. + service.claim(a, "x"); service.submitForReview(a); service.complete(a) + assertEquals(setOf("auth-2"), service.ready(project).map { it.taskId.value }.toSet()) + assertTrue(service.blockers(b).isEmpty()) + } + @Test fun `history survives soft-delete but is empty for an unknown task`() = runBlocking { val id = service.createTask(project, "throwaway", "oops").taskId From 215a951b52f4f4e7600278b1b3cd6f2c879e1f2f Mon Sep 17 00:00:00 2001 From: kami Date: Wed, 24 Jun 2026 17:34:24 +0000 Subject: [PATCH 091/107] =?UTF-8?q?feat(tasks):=20agent-loop=20integration?= =?UTF-8?q?=20=E2=80=94=20task=5Fready=20tool=20+=20claim=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring the work graph into the agent loop: - task_ready (T1): lists tasks ready to work now (TODO, all dependencies satisfied) so an agent looking for work can pick one and claim it. Surfaces work; never assigns (honouring the rejected /tasks/next). Registered in TaskTools — left unwired in the request-driven example workflows, where it would be noise; it belongs in an autonomous work-pull workflow. - claim guard: task_update action=claim appends a WARNING naming any unmet blockers, so an agent knows when it has jumped a dependency. Surfaced, not hard-blocked — status is derived, so we don't fight the reducer. Co-Authored-By: Claude Opus 4.8 --- .../tools/task/TaskReadyTool.kt | 61 +++++++++++++++++++ .../infrastructure/tools/task/TaskTools.kt | 1 + .../tools/task/TaskUpdateTool.kt | 11 +++- .../infrastructure/tools/task/ToolParams.kt | 4 ++ 4 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskReadyTool.kt diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskReadyTool.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskReadyTool.kt new file mode 100644 index 00000000..66e61887 --- /dev/null +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskReadyTool.kt @@ -0,0 +1,61 @@ +package com.correx.infrastructure.tools.task + +import com.correx.core.approvals.Tier +import com.correx.core.events.events.ToolRequest +import com.correx.core.events.types.ProjectId +import com.correx.core.tasks.TaskService +import com.correx.core.tools.contract.Tool +import com.correx.core.tools.contract.ToolCapability +import com.correx.core.tools.contract.ToolExecutor +import com.correx.core.tools.contract.ToolResult +import com.correx.core.tools.contract.ValidationResult +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonObject + +private const val DEFAULT_LIMIT = 20 + +/** + * Agent-facing tool: list tasks that are ready to be worked right now — TODO with every dependency + * satisfied. Read-only, lowest tier. This is how an agent looking for work discovers what it can + * pick up; it then claims one with `task_update action=claim`. It surfaces work, it does not assign. + */ +class TaskReadyTool(private val service: TaskService) : Tool, ToolExecutor { + + override val name: String = "task_ready" + override val description: String = + "List tasks ready to work now — TODO with all dependencies satisfied — when you're looking " + + "for what to pick up. Claim one with task_update action=claim. Optionally scope to a " + + "project. Returns 'id [STATUS] title' lines." + override val parametersSchema: JsonObject = buildJsonObject { + put("type", "object") + putJsonObject("properties") { + putJsonObject("project") { + put("type", "string") + put("description", "Limit to a project (e.g. 'auth'). Omit for the whole board.") + } + putJsonObject("limit") { put("type", "integer"); put("description", "Max results (default $DEFAULT_LIMIT).") } + } + } + override val tier: Tier = Tier.T1 + override val requiredCapabilities: Set = emptySet() + + override fun validateRequest(request: ToolRequest): ValidationResult = ValidationResult.Valid + + override suspend fun execute(request: ToolRequest): ToolResult { + val project = request.stringParam("project")?.let { ProjectId(it) } + val limit = (request.parameters["limit"] as? Number)?.toInt()?.takeIf { it > 0 } ?: DEFAULT_LIMIT + val ready = service.ready(project).take(limit) + val output = if (ready.isEmpty()) { + "no ready tasks" + } else { + ready.joinToString("\n") { "${it.taskId.value} [${it.state.status.name}] ${it.state.title.orEmpty()}".trimEnd() } + } + return ToolResult.Success( + invocationId = request.invocationId, + output = output, + metadata = mapOf("count" to ready.size.toString()), + ) + } +} diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskTools.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskTools.kt index 3c5e61c6..ee93d5e7 100644 --- a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskTools.kt +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskTools.kt @@ -27,6 +27,7 @@ object TaskTools { TaskUpdateTool(service), TaskDeleteTool(service), TaskSearchTool(service), + TaskReadyTool(service), TaskContextTool( TaskContextAssembler(service, retriever, documentResolver, artifactResolver, sessionResolver), ), diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt index bf37d5d7..147184b6 100644 --- a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt @@ -105,13 +105,22 @@ class TaskUpdateTool(private val service: TaskService) : Tool, ToolExecutor { request.stringParam("note")?.let { service.addNote(taskId, TaskNoteAuthor.AGENT, it) } val status = service.getTask(taskId)?.state?.status?.name ?: "UNKNOWN" + val warning = if (action == "claim") claimWarning(taskId) else "" return ToolResult.Success( invocationId = request.invocationId, - output = "Updated $id [$status]", + output = "Updated $id [$status]$warning", metadata = mapOf("taskId" to id, "status" to status), ) } + /** When a claimed task still has unmet blockers, flag it so the agent knows it jumped a dependency. */ + private fun claimWarning(taskId: TaskId): String { + val blockers = service.blockers(taskId) + if (blockers.isEmpty()) return "" + val listed = blockers.joinToString(", ") { "${it.taskId.value} [${it.state.status.name}]" } + return " — WARNING: claimed despite unmet blockers: $listed" + } + private suspend fun applyFieldEdits(request: ToolRequest, taskId: TaskId) { val title = request.stringParam("title") val goal = request.stringParam("goal") diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/ToolParams.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/ToolParams.kt index 5f816711..b422223c 100644 --- a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/ToolParams.kt +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/ToolParams.kt @@ -14,6 +14,10 @@ internal fun ToolRequest.stringParam(name: String): String? = internal fun ToolRequest.listParam(name: String): List = (parameters[name] as? List<*>)?.mapNotNull { it?.toString()?.takeIf(String::isNotBlank) } ?: emptyList() +/** A boolean parameter, accepting a real Boolean or the string "true" (case-insensitive). */ +internal fun ToolRequest.boolParam(name: String): Boolean = + parameters[name] == true || stringParam(name)?.toBoolean() == true + /** * Records the agent's session on the task as a CONTEXT/SESSION link, so the context bundle can * surface the run(s) that created or worked on it (the session resolver inlines status/intent). From 5d48c26ec8c599f4a8b4d36fd4f0a2304f662c92 Mon Sep 17 00:00:00 2001 From: kami Date: Wed, 24 Jun 2026 17:34:24 +0000 Subject: [PATCH 092/107] feat(tasks): duplicate-title guard on task creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The doctrine says "search before creating", but that leans on the LLM; this is the backstop against an agent re-creating a task across runs. TaskService. findDuplicates matches active (non-terminal) same-title tasks, case- and whitespace-insensitively — a DONE/CANCELLED look-alike is not a duplicate, since that work may legitimately recur. - task_create rejects a duplicate, naming the look-alike and offering force=true. - POST /tasks → 409 with the duplicates, or force:true to override; correx task create --force. Co-Authored-By: Claude Opus 4.8 --- .../correx/apps/cli/commands/TaskCommand.kt | 15 ++++-- .../correx/apps/server/routes/TaskRoutes.kt | 10 +++- .../apps/server/routes/TaskRoutesTest.kt | 23 +++++++++ .../com/correx/core/tasks/TaskService.kt | 22 +++++++++ .../com/correx/core/tasks/TaskServiceTest.kt | 18 +++++++ .../tools/task/TaskCreateTool.kt | 29 ++++++++++-- .../tools/task/TaskToolsTest.kt | 47 +++++++++++++++++++ 7 files changed, 156 insertions(+), 8 deletions(-) diff --git a/apps/cli/src/main/kotlin/com/correx/apps/cli/commands/TaskCommand.kt b/apps/cli/src/main/kotlin/com/correx/apps/cli/commands/TaskCommand.kt index a29521ce..70bfce46 100644 --- a/apps/cli/src/main/kotlin/com/correx/apps/cli/commands/TaskCommand.kt +++ b/apps/cli/src/main/kotlin/com/correx/apps/cli/commands/TaskCommand.kt @@ -6,6 +6,7 @@ import com.github.ajalt.clikt.core.CliktCommand import com.github.ajalt.clikt.core.subcommands import com.github.ajalt.clikt.parameters.arguments.argument import com.github.ajalt.clikt.parameters.options.default +import com.github.ajalt.clikt.parameters.options.flag import com.github.ajalt.clikt.parameters.options.multiple import com.github.ajalt.clikt.parameters.options.option import com.github.ajalt.clikt.parameters.options.required @@ -206,6 +207,7 @@ class TaskCreateCommand : TaskHttpCommand("create") { private val goal by option("--goal").required() private val criteria by option("--criteria", help = "Acceptance criterion (repeatable)").multiple() private val paths by option("--path", help = "Affected path/glob (repeatable)").multiple() + private val force by option("--force", help = "Create even if a same-title task exists").flag() override fun run() = http { client -> val payload = buildJsonObject { @@ -214,12 +216,19 @@ class TaskCreateCommand : TaskHttpCommand("create") { put("goal", goal) if (criteria.isNotEmpty()) put("acceptanceCriteria", JsonArray(criteria.map { JsonPrimitive(it) })) if (paths.isNotEmpty()) put("affectedPaths", JsonArray(paths.map { JsonPrimitive(it) })) + if (force) put("force", true) } - val raw = client.post("${baseUrl()}/tasks") { + val resp = client.post("${baseUrl()}/tasks") { contentType(ContentType.Application.Json) setBody(payload) - }.bodyAsText() - if (jsonOut()) println(raw) else println("created ${taskJson.decodeFromString(raw).id}") + } + val raw = resp.bodyAsText() + when { + resp.status == HttpStatusCode.Conflict -> + System.err.println("Possible duplicate(s) — pass --force to create anyway:\n${renderTaskList(taskJson.decodeFromString(raw))}") + jsonOut() -> println(raw) + else -> println("created ${taskJson.decodeFromString(raw).id}") + } } } diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt b/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt index 81a0d994..34bda54d 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt @@ -61,6 +61,8 @@ data class CreateTaskRequest( val goal: String, val acceptanceCriteria: List = emptyList(), val affectedPaths: List = emptyList(), + // Skip the duplicate-title guard and create anyway. + val force: Boolean = false, ) // Null fields are left untouched; a present list (even empty) replaces the stored one. @@ -151,8 +153,14 @@ private fun Route.taskCollectionRoutes(service: TaskService) { } post { val body = call.receive() + val project = ProjectId(body.project) + // Duplicate-title guard (skippable with force): 409 with the look-alikes so the caller reuses one. + val duplicates = if (body.force) emptyList() else service.findDuplicates(project, body.title) + if (duplicates.isNotEmpty()) { + return@post call.respond(HttpStatusCode.Conflict, duplicates.map { it.toResponse() }) + } val task = service.createTask( - projectId = ProjectId(body.project), + projectId = project, title = body.title, goal = body.goal, acceptanceCriteria = body.acceptanceCriteria, diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/routes/TaskRoutesTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/routes/TaskRoutesTest.kt index a56f713e..9ec3c1c0 100644 --- a/apps/server/src/test/kotlin/com/correx/apps/server/routes/TaskRoutesTest.kt +++ b/apps/server/src/test/kotlin/com/correx/apps/server/routes/TaskRoutesTest.kt @@ -307,6 +307,29 @@ class TaskRoutesTest { } } + @Test + fun `POST rejects a duplicate title with 409 and force overrides`(@TempDir tempDir: Path) { + testApplication { + application { configureServer(buildModule(tempDir)) } + val client = createClient {} + client.createTask() // demo-1 "JWT refresh" + + val dup = client.post("/tasks") { + contentType(ContentType.Application.Json) + setBody("""{"project":"demo","title":"jwt refresh","goal":"g"}""") + } + assertEquals(HttpStatusCode.Conflict, dup.status) + val rows = testJson.parseToJsonElement(dup.bodyAsText()) as JsonArray + assertEquals("demo-1", (rows.single() as JsonObject)["id"]!!.jsonPrimitive.content) + + val forced = client.post("/tasks") { + contentType(ContentType.Application.Json) + setBody("""{"project":"demo","title":"jwt refresh","goal":"g","force":true}""") + } + assertEquals(HttpStatusCode.Created, forced.status) + } + } + @Test fun `bad requests are rejected`(@TempDir tempDir: Path) { testApplication { diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskService.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskService.kt index 2f83006b..ad31a48c 100644 --- a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskService.kt +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskService.kt @@ -104,6 +104,23 @@ class TaskService( return TaskGraph.blocking(task, list(projectOf(taskId))) } + /** + * Active (non-terminal) tasks in [projectId] whose title matches [title] after normalization + * (case- and whitespace-insensitive) — the backstop against an agent re-creating a task it + * should have found by search. A blank title never matches; a DONE/CANCELLED look-alike is not + * a duplicate (that work may legitimately recur). + */ + fun findDuplicates(projectId: ProjectId, title: String): List { + val needle = normalizeTitle(title) + if (needle.isEmpty()) return emptyList() + return list(projectId).filter { + it.state.status !in TERMINAL_STATUSES && normalizeTitle(it.state.title.orEmpty()) == needle + } + } + + private fun normalizeTitle(title: String): String = + title.trim().lowercase().replace(WHITESPACE, " ") + suspend fun createTask( projectId: ProjectId, title: String, @@ -207,4 +224,9 @@ class TaskService( ), ) } + + private companion object { + val TERMINAL_STATUSES = setOf(TaskStatus.DONE, TaskStatus.CANCELLED) + val WHITESPACE = Regex("\\s+") + } } diff --git a/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskServiceTest.kt b/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskServiceTest.kt index f9c19936..fea1f8ec 100644 --- a/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskServiceTest.kt +++ b/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskServiceTest.kt @@ -105,6 +105,24 @@ class TaskServiceTest { assertTrue(lines.last().contains("completed")) } + @Test + fun `findDuplicates matches active same-title tasks but ignores terminal ones`() = runBlocking { + service.createTask(project, "Add JWT refresh", "g") // auth-1, active + + // Case- and whitespace-insensitive match. + assertEquals( + listOf("auth-1"), + service.findDuplicates(project, " add jwt REFRESH ").map { it.taskId.value }, + ) + + // A DONE look-alike is not a duplicate — that work may legitimately recur. + val done = service.createTask(project, "Rotate keys", "g").taskId + service.claim(done, "x"); service.submitForReview(done); service.complete(done) + assertTrue(service.findDuplicates(project, "Rotate keys").isEmpty()) + + assertTrue(service.findDuplicates(project, "totally new").isEmpty()) + } + @Test fun `ready surfaces unblocked work and blockers explain the wait`() = runBlocking { val a = service.createTask(project, "A", "g").taskId // auth-1 diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskCreateTool.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskCreateTool.kt index 357860d3..5792dd0b 100644 --- a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskCreateTool.kt +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskCreateTool.kt @@ -51,6 +51,10 @@ class TaskCreateTool(private val service: TaskService) : Tool, ToolExecutor { putJsonObject("items") { put("type", "string") } put("description", "Globs/paths this task is expected to touch.") } + putJsonObject("force") { + put("type", "boolean") + put("description", "Create even if an active task with the same title exists. Default false.") + } } put( "required", @@ -72,10 +76,8 @@ class TaskCreateTool(private val service: TaskService) : Tool, ToolExecutor { } override suspend fun execute(request: ToolRequest): ToolResult { - val invalid = validateRequest(request) as? ValidationResult.Invalid - if (invalid != null) { - return ToolResult.Failure(request.invocationId, invalid.reason, recoverable = false) - } + val precheckFailure = precheck(request) + if (precheckFailure != null) return precheckFailure val task = service.createTask( projectId = ProjectId(request.stringParam("project")!!), title = request.stringParam("title")!!, @@ -92,4 +94,23 @@ class TaskCreateTool(private val service: TaskService) : Tool, ToolExecutor { metadata = mapOf("taskId" to task.taskId.value, "status" to task.state.status.name), ) } + + /** Required-field validation, then a duplicate-title guard (unless force) — null when OK to create. */ + private fun precheck(request: ToolRequest): ToolResult.Failure? { + val invalid = validateRequest(request) as? ValidationResult.Invalid + if (invalid != null) return ToolResult.Failure(request.invocationId, invalid.reason, recoverable = false) + val duplicates = if (request.boolParam("force")) { + emptyList() + } else { + service.findDuplicates(ProjectId(request.stringParam("project")!!), request.stringParam("title")!!) + } + return duplicates.takeIf { it.isNotEmpty() }?.let { + val listed = it.joinToString(", ") { d -> "${d.taskId.value} '${d.state.title.orEmpty()}' [${d.state.status.name}]" } + ToolResult.Failure( + request.invocationId, + "Possible duplicate(s): $listed. Reuse one (task_context/task_update), or pass force=true.", + recoverable = false, + ) + } + } } diff --git a/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt b/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt index 3c9707d3..e049dcdc 100644 --- a/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt +++ b/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt @@ -30,6 +30,7 @@ class TaskToolsTest { private val update = TaskUpdateTool(service) private val delete = TaskDeleteTool(service) private val search = TaskSearchTool(service) + private val ready = TaskReadyTool(service) private val context = TaskContextTool(TaskContextAssembler(service)) private var counter = 0 @@ -155,6 +156,52 @@ class TaskToolsTest { assertTrue((miss as ToolResult.Success).output.contains("no tasks match")) } + @Test + fun `task_ready lists unblocked TODO tasks only`() = runBlocking { + create.execute(request("task_create", mapOf("project" to "auth", "title" to "A", "goal" to "g"))) // auth-1 + create.execute(request("task_create", mapOf("project" to "auth", "title" to "B", "goal" to "g"))) // auth-2 + // auth-2 depends on auth-1 (still TODO) → auth-2 not ready. + update.execute( + request( + "task_update", + mapOf("id" to "auth-2", "link_target" to "auth-1", "link_type" to "depends_on", "link_kind" to "task"), + ), + ) + + val out = (ready.execute(request("task_ready", emptyMap())) as ToolResult.Success).output + assertTrue(out.contains("auth-1")) + assertFalse(out.contains("auth-2")) + } + + @Test + fun `task_create blocks a duplicate title and force overrides`() = runBlocking { + create.execute(request("task_create", mapOf("project" to "auth", "title" to "JWT refresh", "goal" to "g"))) + + val dup = create.execute(request("task_create", mapOf("project" to "auth", "title" to "jwt refresh", "goal" to "g"))) + assertTrue(dup is ToolResult.Failure) + assertTrue((dup as ToolResult.Failure).reason.contains("duplicate", ignoreCase = true)) + + val forced = create.execute( + request("task_create", mapOf("project" to "auth", "title" to "jwt refresh", "goal" to "g", "force" to true)), + ) + assertTrue(forced is ToolResult.Success) + } + + @Test + fun `task_update claim warns when blockers are unmet`() = runBlocking { + create.execute(request("task_create", mapOf("project" to "auth", "title" to "A", "goal" to "g"))) // auth-1 + create.execute(request("task_create", mapOf("project" to "auth", "title" to "B", "goal" to "g"))) // auth-2 + update.execute( + request( + "task_update", + mapOf("id" to "auth-2", "link_target" to "auth-1", "link_type" to "depends_on", "link_kind" to "task"), + ), + ) + + val claimed = update.execute(request("task_update", mapOf("id" to "auth-2", "action" to "claim"))) + assertTrue((claimed as ToolResult.Success).output.contains("WARNING")) + } + @Test fun `task_delete tombstones the task`() = runBlocking { val id = createTask() From 693e38ffac1870da1dc935ad42946b583cd0c54d Mon Sep 17 00:00:00 2001 From: kami Date: Wed, 24 Jun 2026 18:13:06 +0000 Subject: [PATCH 093/107] feat(toolintent): read-before-write gate (anti-hallucination) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An LLM that edits or overwrites a file it never read is acting on hallucinated contents. This plane-2 ToolCallRule blocks it: dispatching on FILE_WRITE (covers file_write and file_edit), a write to a path that exists on disk but was not file_read earlier this session is BLOCKED. A brand-new file passes — you can't read what doesn't exist, and creating is legitimate. - ReadFilesProjection folds the session's file_read events (request + completion, so a failed read doesn't count) into the set of paths read, mirroring EgressAllowlistProjection; threaded into ToolCallAssessmentInput.sessionReadPaths via SessionOrchestrator.resolveSessionReadPaths. - Read paths and the write target are both resolved through the probe (toRealPath), so spelling differences and symlinks can't dodge the gate. - The orchestrator already feeds a block's rationale back as a tool result, so the agent reads the file and retries; the rejected-event reason now carries that rationale too (specific audit trail instead of a generic string). Co-Authored-By: Claude Opus 4.8 --- .../kotlin/com/correx/apps/server/Main.kt | 2 + .../orchestration/SessionOrchestrator.kt | 19 ++++- .../core/toolintent/ReadFilesProjection.kt | 54 ++++++++++++ .../correx/core/toolintent/ToolCallRule.kt | 4 + .../toolintent/rules/ReadBeforeWriteRule.kt | 72 ++++++++++++++++ .../toolintent/ReadBeforeWriteRuleTest.kt | 80 ++++++++++++++++++ .../toolintent/ReadFilesProjectionTest.kt | 83 +++++++++++++++++++ 7 files changed, 313 insertions(+), 1 deletion(-) create mode 100644 core/toolintent/src/main/kotlin/com/correx/core/toolintent/ReadFilesProjection.kt create mode 100644 core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/ReadBeforeWriteRule.kt create mode 100644 core/toolintent/src/test/kotlin/com/correx/core/toolintent/ReadBeforeWriteRuleTest.kt create mode 100644 core/toolintent/src/test/kotlin/com/correx/core/toolintent/ReadFilesProjectionTest.kt diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt index 849a9866..36683aa2 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt @@ -61,6 +61,7 @@ import com.correx.core.toolintent.rules.ExecInterpreterRule import com.correx.core.toolintent.rules.ManifestContainmentRule import com.correx.core.toolintent.rules.NetworkHostRule import com.correx.core.toolintent.rules.PathContainmentRule +import com.correx.core.toolintent.rules.ReadBeforeWriteRule import com.correx.apps.server.freestyle.FreestyleDriver import com.correx.apps.server.inference.summarizeWithInference import com.correx.core.kernel.orchestration.JournalCompactionService @@ -261,6 +262,7 @@ fun main() { val toolCallAssessor = ToolCallAssessor( rules = listOf( PathContainmentRule(), + ReadBeforeWriteRule(), ManifestContainmentRule(), ExecInterpreterRule(toolsConfig.interpreterExecutables.toSet()), NetworkHostRule( diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt index 34e938ed..624829e2 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt @@ -69,6 +69,7 @@ import com.correx.core.events.events.ToolReceipt import com.correx.core.events.events.ToolRequest import com.correx.core.events.risk.RiskAction import com.correx.core.events.risk.RiskSummary +import com.correx.core.toolintent.ReadFilesProjection import com.correx.core.toolintent.ToolCallAssessmentInput import com.correx.core.toolintent.ToolCallAssessor import com.correx.core.toolintent.WorkspacePolicy @@ -697,7 +698,10 @@ abstract class SessionOrchestrator( sessionId = sessionId, toolName = toolCall.function.name, tier = tier, - reason = "blocked by tool-call policy", + // Record the specific rule rationale, not a generic string, so the audit + // trail (and task history) shows why a call was blocked. + reason = assessment.rationale.joinToString("; ") + .ifBlank { "blocked by tool-call policy" }, ), ) val sourceId = toolCall.id ?: invocationId.value @@ -965,6 +969,7 @@ abstract class SessionOrchestrator( paramRoles = tool?.paramRoles ?: emptyMap(), writeManifest = writeManifest, sessionEgressHosts = sessionEgressHosts, + sessionReadPaths = resolveSessionReadPaths(sessionId), ), ) emit( @@ -994,6 +999,18 @@ abstract class SessionOrchestrator( .fold(projection.initial()) { acc, event -> projection.apply(acc, event) } } + /** + * Folds this session's file_read tool events through [ReadFilesProjection] into the set of paths + * it has successfully read — the input to [com.correx.core.toolintent.rules.ReadBeforeWriteRule]. + * Replay-safe (reads the log, never live state); empty before any read completes. + */ + internal fun resolveSessionReadPaths(sessionId: SessionId): Set { + val projection = ReadFilesProjection(sessionId) + return eventStore.read(sessionId) + .fold(projection.initial()) { acc, event -> projection.apply(acc, event) } + .readPaths + } + private suspend fun buildSchemaEntries( responseFormat: ResponseFormat, stageId: StageId, diff --git a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/ReadFilesProjection.kt b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/ReadFilesProjection.kt new file mode 100644 index 00000000..5c3af946 --- /dev/null +++ b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/ReadFilesProjection.kt @@ -0,0 +1,54 @@ +package com.correx.core.toolintent + +import com.correx.core.events.events.StoredEvent +import com.correx.core.events.events.ToolExecutionCompletedEvent +import com.correx.core.events.events.ToolInvocationRequestedEvent +import com.correx.core.events.types.SessionId +import com.correx.core.sessions.projections.Projection +import com.correx.core.toolintent.rules.candidatePathStrings + +/** Reads still awaiting their completion event, plus the paths confirmed read so far. */ +data class ReadFilesState( + val pending: Map> = emptyMap(), + val readPaths: Set = emptySet(), +) + +/** + * Folds one session's tool events into the set of file paths it has successfully read. A `file_read` + * counts only once its [ToolExecutionCompletedEvent] lands — a read that failed (file not found) + * never enters the set, so it can't satisfy [com.correx.core.toolintent.rules.ReadBeforeWriteRule]. + * Paths are kept as the raw arguments; the rule resolves both sides through the probe, so + * `./Foo.kt` read and `Foo.kt` written still match. The same projection precedent as + * [com.correx.core.sessions.projections.EgressAllowlistProjection]. + */ +class ReadFilesProjection(private val sessionId: SessionId) : Projection { + + override fun initial(): ReadFilesState = ReadFilesState() + + override fun apply(state: ReadFilesState, event: StoredEvent): ReadFilesState = + when (val payload = event.payload) { + is ToolInvocationRequestedEvent -> recordPending(state, payload) + is ToolExecutionCompletedEvent -> confirmRead(state, payload) + else -> state + } + + private fun recordPending(state: ReadFilesState, payload: ToolInvocationRequestedEvent): ReadFilesState { + if (payload.sessionId != sessionId || payload.toolName != FILE_READ_TOOL) return state + val paths = candidatePathStrings(emptyMap(), payload.request.parameters) + if (paths.isEmpty()) return state + return state.copy(pending = state.pending + (payload.invocationId.value to paths)) + } + + private fun confirmRead(state: ReadFilesState, payload: ToolExecutionCompletedEvent): ReadFilesState { + val paths = state.pending[payload.invocationId.value] + if (payload.sessionId != sessionId || paths == null) return state + return state.copy( + pending = state.pending - payload.invocationId.value, + readPaths = state.readPaths + paths, + ) + } + + private companion object { + const val FILE_READ_TOOL = "file_read" + } +} diff --git a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/ToolCallRule.kt b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/ToolCallRule.kt index 208f2109..719e32e0 100644 --- a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/ToolCallRule.kt +++ b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/ToolCallRule.kt @@ -29,6 +29,10 @@ data class ToolCallAssessmentInput( // EgressAllowlistProjection). Unioned with the static allow-list at the network gate; empty // means "no per-session grants", which never narrows the static allow-list. val sessionEgressHosts: Set = emptySet(), + // Paths this session has successfully file_read (folded via ReadFilesProjection). The input to + // ReadBeforeWriteRule; empty means nothing has been read yet (so any write to an existing file + // is unread). Raw argument strings — the rule resolves both sides through the probe. + val sessionReadPaths: Set = emptySet(), ) data class ToolCallAssessment( diff --git a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/ReadBeforeWriteRule.kt b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/ReadBeforeWriteRule.kt new file mode 100644 index 00000000..57492985 --- /dev/null +++ b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/ReadBeforeWriteRule.kt @@ -0,0 +1,72 @@ +package com.correx.core.toolintent.rules + +import com.correx.core.events.events.ToolCallObservation +import com.correx.core.events.risk.RiskAction +import com.correx.core.toolintent.ToolCallAssessment +import com.correx.core.toolintent.ToolCallAssessmentInput +import com.correx.core.toolintent.ToolCallRule +import com.correx.core.toolintent.maxAction +import com.correx.core.tools.contract.ToolCapability +import com.correx.core.validation.model.ValidationIssue +import com.correx.core.validation.model.ValidationSeverity +import java.nio.file.Path + +/** + * Read-before-write gate (anti-hallucination). Dispatches on FILE_WRITE, covering both file_write + * and file_edit. A write to a path that exists on disk but was not file_read earlier this session + * is BLOCKED — the classic "edited a file whose contents it never saw" failure. A brand-new file + * (not on disk) passes: you cannot read what does not exist, and creating one is legitimate. + * + * The block message names the path; the orchestrator feeds the rationale back as a tool result, so + * the agent reads the file and retries. Read paths and the write target are both resolved through + * the probe (toRealPath), so `./Foo.kt` read then `Foo.kt` written match, and symlinks can't dodge it. + */ +class ReadBeforeWriteRule : ToolCallRule { + + override fun appliesTo(capabilities: Set): Boolean = + ToolCapability.FILE_WRITE in capabilities + + override fun assess(input: ToolCallAssessmentInput): ToolCallAssessment { + val root = input.workspace.workspaceRoot + val readReal = input.sessionReadPaths.map { realOf(input, root, it) }.toSet() + + val issues = mutableListOf() + val observations = mutableListOf() + var disposition = RiskAction.PROCEED + + for (raw in candidatePathStrings(input.paramRoles, input.request.parameters)) { + val resolvedInput = resolveInput(root, raw) + val exists = input.probe.exists(resolvedInput) + val read = input.probe.resolveReal(resolvedInput) in readReal + + observations += ToolCallObservation( + ruleCode = RULE_CODE, + facts = mapOf("path" to raw, "exists" to exists.toString(), "read" to read.toString()), + ) + + if (exists && !read) { + issues += ValidationIssue( + code = RULE_CODE, + message = "Tool '${input.request.toolName}' targets '$raw', which you have not " + + "read this session — call file_read on it before writing or editing.", + severity = ValidationSeverity.ERROR, + ) + disposition = maxAction(disposition, RiskAction.BLOCK) + } + } + + return ToolCallAssessment(issues = issues, observations = observations, disposition = disposition) + } + + private fun resolveInput(root: Path, raw: String): Path { + val candidate = Path.of(raw) + return if (candidate.isAbsolute) candidate else root.resolve(candidate) + } + + private fun realOf(input: ToolCallAssessmentInput, root: Path, raw: String): Path = + input.probe.resolveReal(resolveInput(root, raw)) + + private companion object { + const val RULE_CODE = "READ_BEFORE_WRITE" + } +} diff --git a/core/toolintent/src/test/kotlin/com/correx/core/toolintent/ReadBeforeWriteRuleTest.kt b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/ReadBeforeWriteRuleTest.kt new file mode 100644 index 00000000..fe84835f --- /dev/null +++ b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/ReadBeforeWriteRuleTest.kt @@ -0,0 +1,80 @@ +package com.correx.core.toolintent + +import com.correx.core.events.events.ToolRequest +import com.correx.core.events.risk.RiskAction +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.events.types.ToolInvocationId +import com.correx.core.toolintent.rules.ReadBeforeWriteRule +import com.correx.core.tools.contract.ToolCapability +import java.nio.file.Path +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ReadBeforeWriteRuleTest { + + private val workspace = Path.of("/work/project") + private val rule = ReadBeforeWriteRule() + + /** A probe where existence is configurable; resolveReal just normalizes (no symlinks). */ + private class FakeProbe(private val existing: Set = emptySet()) : WorldProbe { + override fun exists(path: Path): Boolean = path.toAbsolutePath().normalize() in existing + override fun resolveReal(path: Path): Path = path.toAbsolutePath().normalize() + } + + private fun abs(p: String): Path = Path.of(p).toAbsolutePath().normalize() + + private fun input( + pathArg: String, + probe: WorldProbe, + read: Set = emptySet(), + tool: String = "file_edit", + ) = ToolCallAssessmentInput( + request = ToolRequest(ToolInvocationId("i"), SessionId("s"), StageId("st"), tool, mapOf("path" to pathArg)), + capabilities = setOf(ToolCapability.FILE_WRITE), + workspace = WorkspacePolicy(workspace, emptyList()), + probe = probe, + sessionReadPaths = read, + ) + + @Test + fun `applies only to FILE_WRITE`() { + assertTrue(rule.appliesTo(setOf(ToolCapability.FILE_WRITE))) + assertFalse(rule.appliesTo(setOf(ToolCapability.FILE_READ))) + assertFalse(rule.appliesTo(emptySet())) + } + + @Test + fun `editing an existing file that was not read is blocked`() { + val target = "/work/project/src/A.kt" + val r = rule.assess(input(target, FakeProbe(existing = setOf(abs(target))))) + assertEquals(RiskAction.BLOCK, r.disposition) + assertEquals("READ_BEFORE_WRITE", r.issues.single().code) + assertEquals("false", r.observations.single().facts["read"]) + } + + @Test + fun `editing an existing file that was read proceeds`() { + val target = "/work/project/src/A.kt" + val r = rule.assess(input(target, FakeProbe(existing = setOf(abs(target))), read = setOf(target))) + assertEquals(RiskAction.PROCEED, r.disposition) + assertTrue(r.issues.isEmpty()) + } + + @Test + fun `creating a brand-new file proceeds without a prior read`() { + val target = "/work/project/src/New.kt" // not in existing → new file + val r = rule.assess(input(target, FakeProbe(existing = emptySet()), tool = "file_write")) + assertEquals(RiskAction.PROCEED, r.disposition) + } + + @Test + fun `a relative read of the same file satisfies an absolute write`() { + val target = "/work/project/src/A.kt" + // read recorded as a workspace-relative path; resolves to the same real path as the write. + val r = rule.assess(input(target, FakeProbe(existing = setOf(abs(target))), read = setOf("src/A.kt"))) + assertEquals(RiskAction.PROCEED, r.disposition) + } +} diff --git a/core/toolintent/src/test/kotlin/com/correx/core/toolintent/ReadFilesProjectionTest.kt b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/ReadFilesProjectionTest.kt new file mode 100644 index 00000000..45e59829 --- /dev/null +++ b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/ReadFilesProjectionTest.kt @@ -0,0 +1,83 @@ +package com.correx.core.toolintent + +import com.correx.core.approvals.Tier +import com.correx.core.events.events.EventMetadata +import com.correx.core.events.events.EventPayload +import com.correx.core.events.events.StoredEvent +import com.correx.core.events.events.ToolExecutionCompletedEvent +import com.correx.core.events.events.ToolInvocationRequestedEvent +import com.correx.core.events.events.ToolReceipt +import com.correx.core.events.events.ToolRequest +import com.correx.core.events.types.EventId +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.events.types.ToolInvocationId +import kotlinx.datetime.Instant +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ReadFilesProjectionTest { + + private val session = SessionId("s") + private val projection = ReadFilesProjection(session) + + private var seq = 0L + private fun stored(payload: EventPayload) = StoredEvent( + metadata = EventMetadata(EventId("e-${seq}"), session, Instant.parse("2026-01-01T00:00:00Z"), 1, null, null), + sequence = ++seq, + sessionSequence = seq, + payload = payload, + ) + + private fun readRequested(invId: String, path: String) = stored( + ToolInvocationRequestedEvent( + invocationId = ToolInvocationId(invId), + sessionId = session, + stageId = StageId("st"), + toolName = "file_read", + tier = Tier.T1, + request = ToolRequest(ToolInvocationId(invId), session, StageId("st"), "file_read", mapOf("path" to path)), + ), + ) + + private fun completed(invId: String, tool: String = "file_read") = stored( + ToolExecutionCompletedEvent( + invocationId = ToolInvocationId(invId), + sessionId = session, + toolName = tool, + receipt = ToolReceipt( + invocationId = ToolInvocationId(invId), + toolName = tool, + exitCode = 0, + outputSummary = "ok", + durationMs = 1, + tier = Tier.T1, + timestamp = Instant.parse("2026-01-01T00:00:00Z"), + ), + ), + ) + + private fun fold(events: List) = + events.fold(projection.initial()) { acc, e -> projection.apply(acc, e) } + + @Test + fun `a completed file_read adds the path to the read set`() { + val state = fold(listOf(readRequested("r1", "src/A.kt"), completed("r1"))) + assertEquals(setOf("src/A.kt"), state.readPaths) + } + + @Test + fun `a read still pending its completion does not count`() { + val state = fold(listOf(readRequested("r1", "src/A.kt"))) + assertTrue(state.readPaths.isEmpty()) + assertTrue(state.pending.containsKey("r1")) + } + + @Test + fun `a non-read tool completion is ignored`() { + // file_write request never enters pending, so its completion adds nothing. + val state = fold(listOf(completed("w1", tool = "file_write"))) + assertTrue(state.readPaths.isEmpty()) + } +} From 146f96a6fd7f59b3559bc9c22fd8dd4b062646e4 Mon Sep 17 00:00:00 2001 From: kami Date: Wed, 24 Jun 2026 18:27:08 +0000 Subject: [PATCH 094/107] feat(toolintent): reference-must-exist gate (anti-hallucination) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A file_read of a path that is inside the workspace but does not exist is now BLOCKED with "no such path" instead of silently returning empty — so an agent that hallucinated a filename fails loud and self-corrects (list the directory, fix the name) rather than proceeding on an absent reference. Out-of-workspace targets stay PathContainmentRule's concern, so the gates don't double-handle. Co-Authored-By: Claude Opus 4.8 --- .../kotlin/com/correx/apps/server/Main.kt | 2 + .../toolintent/rules/ReferenceExistsRule.kt | 62 +++++++++++++++++++ .../toolintent/ReferenceExistsRuleTest.kt | 62 +++++++++++++++++++ 3 files changed, 126 insertions(+) create mode 100644 core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/ReferenceExistsRule.kt create mode 100644 core/toolintent/src/test/kotlin/com/correx/core/toolintent/ReferenceExistsRuleTest.kt diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt index 36683aa2..93621ec2 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt @@ -62,6 +62,7 @@ import com.correx.core.toolintent.rules.ManifestContainmentRule import com.correx.core.toolintent.rules.NetworkHostRule import com.correx.core.toolintent.rules.PathContainmentRule import com.correx.core.toolintent.rules.ReadBeforeWriteRule +import com.correx.core.toolintent.rules.ReferenceExistsRule import com.correx.apps.server.freestyle.FreestyleDriver import com.correx.apps.server.inference.summarizeWithInference import com.correx.core.kernel.orchestration.JournalCompactionService @@ -263,6 +264,7 @@ fun main() { rules = listOf( PathContainmentRule(), ReadBeforeWriteRule(), + ReferenceExistsRule(), ManifestContainmentRule(), ExecInterpreterRule(toolsConfig.interpreterExecutables.toSet()), NetworkHostRule( diff --git a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/ReferenceExistsRule.kt b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/ReferenceExistsRule.kt new file mode 100644 index 00000000..56d9133f --- /dev/null +++ b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/ReferenceExistsRule.kt @@ -0,0 +1,62 @@ +package com.correx.core.toolintent.rules + +import com.correx.core.events.events.ToolCallObservation +import com.correx.core.events.risk.RiskAction +import com.correx.core.toolintent.ToolCallAssessment +import com.correx.core.toolintent.ToolCallAssessmentInput +import com.correx.core.toolintent.ToolCallRule +import com.correx.core.toolintent.maxAction +import com.correx.core.tools.contract.ToolCapability +import com.correx.core.validation.model.ValidationIssue +import com.correx.core.validation.model.ValidationSeverity +import java.nio.file.Path + +/** + * Reference-must-exist gate (anti-hallucination). Dispatches on FILE_READ: a read of a path that is + * inside the workspace but does not exist is BLOCKED with "no such path", so an agent that + * hallucinated a filename fails loud and self-corrects (list the directory, fix the name) instead of + * proceeding on an empty/absent read. Out-of-workspace targets are [PathContainmentRule]'s concern + * (it prompts), so this gate stays silent on them to avoid double-handling. + */ +class ReferenceExistsRule : ToolCallRule { + + override fun appliesTo(capabilities: Set): Boolean = + ToolCapability.FILE_READ in capabilities + + override fun assess(input: ToolCallAssessmentInput): ToolCallAssessment { + val workspaceReal = input.probe.resolveReal(input.workspace.workspaceRoot) + val root = input.workspace.workspaceRoot + + val issues = mutableListOf() + val observations = mutableListOf() + var disposition = RiskAction.PROCEED + + for (raw in candidatePathStrings(input.paramRoles, input.request.parameters)) { + val candidate = Path.of(raw) + val resolvedInput = if (candidate.isAbsolute) candidate else root.resolve(candidate) + val exists = input.probe.exists(resolvedInput) + val inWorkspace = input.probe.resolveReal(resolvedInput).startsWith(workspaceReal) + + observations += ToolCallObservation( + ruleCode = RULE_CODE, + facts = mapOf("path" to raw, "exists" to exists.toString(), "inWorkspace" to inWorkspace.toString()), + ) + + if (inWorkspace && !exists) { + issues += ValidationIssue( + code = RULE_CODE, + message = "Tool '${input.request.toolName}' references '$raw', which does not " + + "exist — list the directory or fix the path; do not assume its contents.", + severity = ValidationSeverity.ERROR, + ) + disposition = maxAction(disposition, RiskAction.BLOCK) + } + } + + return ToolCallAssessment(issues = issues, observations = observations, disposition = disposition) + } + + private companion object { + const val RULE_CODE = "REFERENCE_EXISTS" + } +} diff --git a/core/toolintent/src/test/kotlin/com/correx/core/toolintent/ReferenceExistsRuleTest.kt b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/ReferenceExistsRuleTest.kt new file mode 100644 index 00000000..13ff5d96 --- /dev/null +++ b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/ReferenceExistsRuleTest.kt @@ -0,0 +1,62 @@ +package com.correx.core.toolintent + +import com.correx.core.events.events.ToolRequest +import com.correx.core.events.risk.RiskAction +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.events.types.ToolInvocationId +import com.correx.core.toolintent.rules.ReferenceExistsRule +import com.correx.core.tools.contract.ToolCapability +import java.nio.file.Path +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ReferenceExistsRuleTest { + + private val workspace = Path.of("/work/project") + private val rule = ReferenceExistsRule() + + private class FakeProbe(private val existing: Set = emptySet()) : WorldProbe { + override fun exists(path: Path): Boolean = path.toAbsolutePath().normalize() in existing + override fun resolveReal(path: Path): Path = path.toAbsolutePath().normalize() + } + + private fun abs(p: String): Path = Path.of(p).toAbsolutePath().normalize() + + private fun input(pathArg: String, probe: WorldProbe) = ToolCallAssessmentInput( + request = ToolRequest(ToolInvocationId("i"), SessionId("s"), StageId("st"), "file_read", mapOf("path" to pathArg)), + capabilities = setOf(ToolCapability.FILE_READ), + workspace = WorkspacePolicy(workspace, emptyList()), + probe = probe, + ) + + @Test + fun `applies only to FILE_READ`() { + assertTrue(rule.appliesTo(setOf(ToolCapability.FILE_READ))) + assertFalse(rule.appliesTo(setOf(ToolCapability.FILE_WRITE))) + } + + @Test + fun `reading a non-existent in-workspace path is blocked`() { + val r = rule.assess(input("/work/project/src/Ghost.kt", FakeProbe(existing = emptySet()))) + assertEquals(RiskAction.BLOCK, r.disposition) + assertEquals("REFERENCE_EXISTS", r.issues.single().code) + } + + @Test + fun `reading an existing path proceeds`() { + val target = "/work/project/src/A.kt" + val r = rule.assess(input(target, FakeProbe(existing = setOf(abs(target))))) + assertEquals(RiskAction.PROCEED, r.disposition) + assertTrue(r.issues.isEmpty()) + } + + @Test + fun `a non-existent path outside the workspace is left to other gates`() { + val r = rule.assess(input("/tmp/elsewhere.txt", FakeProbe(existing = emptySet()))) + assertEquals(RiskAction.PROCEED, r.disposition) + assertEquals("false", r.observations.single().facts["inWorkspace"]) + } +} From 78e7a4fefb44778638d7789701c301f101ed6dff Mon Sep 17 00:00:00 2001 From: kami Date: Wed, 24 Jun 2026 18:43:17 +0000 Subject: [PATCH 095/107] feat(tasks): dependency hard gate on claim Harden the claim warning into a block: task_update action=claim on a task with unmet blockers is now rejected (no mutation) with the blocker list, instead of claiming-with-a-warning. Escapable with force=true, which claims anyway and keeps the "claimed despite unmet blockers" warning for the audit trail. Co-Authored-By: Claude Opus 4.8 --- .../tools/task/TaskUpdateTool.kt | 19 +++++++++++++++++-- .../tools/task/TaskToolsTest.kt | 14 +++++++++++--- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt index 147184b6..bc9a5677 100644 --- a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt @@ -69,6 +69,10 @@ class TaskUpdateTool(private val service: TaskService) : Tool, ToolExecutor { put("description", "What the link points at: TASK, DOC, ARTIFACT, SESSION. Omit to infer from the target id.") } putJsonObject("note") { put("type", "string"); put("description", "Append an agent note.") } + putJsonObject("force") { + put("type", "boolean") + put("description", "Claim even if the task has unmet blockers. Default false.") + } } put("required", buildJsonArray { add(JsonPrimitive("id")) }) } @@ -86,9 +90,11 @@ class TaskUpdateTool(private val service: TaskService) : Tool, ToolExecutor { val taskId = TaskId(id) if (service.getTask(taskId) == null) return fail(request, "No such task: $id") + val action = request.stringParam("action") + claimBlock(request, taskId, action)?.let { return it } + applyFieldEdits(request, taskId) - val action = request.stringParam("action") if (action != null && !applyAction(taskId, action, request)) { return fail(request, "Unknown action '$action'.") } @@ -113,7 +119,16 @@ class TaskUpdateTool(private val service: TaskService) : Tool, ToolExecutor { ) } - /** When a claimed task still has unmet blockers, flag it so the agent knows it jumped a dependency. */ + /** Refuse to claim a task with unmet blockers (escapable with force) — fail before any mutation. */ + private fun claimBlock(request: ToolRequest, taskId: TaskId, action: String?): ToolResult.Failure? { + if (action != "claim" || request.boolParam("force")) return null + val unmet = service.blockers(taskId) + if (unmet.isEmpty()) return null + val listed = unmet.joinToString(", ") { "${it.taskId.value} [${it.state.status.name}]" } + return fail(request, "Cannot claim ${taskId.value} — unmet blockers: $listed. Complete them, or pass force=true.") + } + + /** When a force-claimed task still has unmet blockers, flag it so the agent knows it jumped a dependency. */ private fun claimWarning(taskId: TaskId): String { val blockers = service.blockers(taskId) if (blockers.isEmpty()) return "" diff --git a/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt b/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt index e049dcdc..20abf70a 100644 --- a/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt +++ b/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt @@ -188,7 +188,7 @@ class TaskToolsTest { } @Test - fun `task_update claim warns when blockers are unmet`() = runBlocking { + fun `task_update blocks claiming over unmet blockers, force overrides with a warning`() = runBlocking { create.execute(request("task_create", mapOf("project" to "auth", "title" to "A", "goal" to "g"))) // auth-1 create.execute(request("task_create", mapOf("project" to "auth", "title" to "B", "goal" to "g"))) // auth-2 update.execute( @@ -198,8 +198,16 @@ class TaskToolsTest { ), ) - val claimed = update.execute(request("task_update", mapOf("id" to "auth-2", "action" to "claim"))) - assertTrue((claimed as ToolResult.Success).output.contains("WARNING")) + // Plain claim is blocked — auth-1 is unfinished. + val blocked = update.execute(request("task_update", mapOf("id" to "auth-2", "action" to "claim"))) + assertTrue(blocked is ToolResult.Failure) + assertTrue((blocked as ToolResult.Failure).reason.contains("blocker", ignoreCase = true)) + assertEquals(TaskStatus.TODO, service.getTask(TaskId("auth-2"))!!.state.status) // not mutated + + // force=true claims anyway, with a warning surfaced. + val forced = update.execute(request("task_update", mapOf("id" to "auth-2", "action" to "claim", "force" to true))) + assertTrue((forced as ToolResult.Success).output.contains("WARNING")) + assertEquals(TaskStatus.IN_PROGRESS, service.getTask(TaskId("auth-2"))!!.state.status) } @Test From 9b003d02eac518f809040f2a23e55c607a08710a Mon Sep 17 00:00:00 2001 From: kami Date: Wed, 24 Jun 2026 19:46:44 +0000 Subject: [PATCH 096/107] refactor(events): record tool capabilities on the invocation event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capability is the canonical signal the plane-2 gates dispatch on, but ToolInvocationRequestedEvent only carried the tool name — so ReadFilesProjection classified reads by a hardcoded "file_read" string, bypassing the abstraction and (worse) re-deriving a semantic fact at replay from whatever the name meant. That violates the event-sourcing invariant that replay reads recorded facts, not live state. Now the tool's requiredCapabilities are recorded on the event at emission, and the projection classifies by ToolCapability.FILE_READ. To let the lowest module carry it, ToolCapability moves from core:tools into core:events keeping its package, so every existing import resolves unchanged and core:tools imports it from below. The field is defaulted, so pre-existing events deserialize as empty. Co-Authored-By: Claude Opus 4.8 --- .../correx/core/events/events/ToolEvents.kt | 5 +++++ .../core/tools/contract/ToolCapability.kt | 21 +++++++++++++++++++ .../orchestration/SessionOrchestrator.kt | 1 + .../core/toolintent/ReadFilesProjection.kt | 18 +++++++--------- .../toolintent/ReadFilesProjectionTest.kt | 2 ++ .../core/tools/contract/ToolCapability.kt | 9 -------- 6 files changed, 37 insertions(+), 19 deletions(-) create mode 100644 core/events/src/main/kotlin/com/correx/core/tools/contract/ToolCapability.kt delete mode 100644 core/tools/src/main/kotlin/com/correx/core/tools/contract/ToolCapability.kt diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/ToolEvents.kt b/core/events/src/main/kotlin/com/correx/core/events/events/ToolEvents.kt index b6a22f1f..66c33901 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/events/ToolEvents.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/events/ToolEvents.kt @@ -4,6 +4,7 @@ import com.correx.core.approvals.Tier import com.correx.core.events.types.SessionId import com.correx.core.events.types.StageId import com.correx.core.events.types.ToolInvocationId +import com.correx.core.tools.contract.ToolCapability import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -52,6 +53,10 @@ data class ToolInvocationRequestedEvent( val toolName: String, val tier: Tier, val request: ToolRequest, + // The tool's declared capabilities, recorded at emission so replay classifies the call by what + // it could actually do (e.g. FILE_READ/FILE_WRITE) without re-deriving from the live registry. + // Defaulted for backward compatibility: events written before this field deserialize as empty. + val capabilities: Set = emptySet(), ) : EventPayload @Serializable diff --git a/core/events/src/main/kotlin/com/correx/core/tools/contract/ToolCapability.kt b/core/events/src/main/kotlin/com/correx/core/tools/contract/ToolCapability.kt new file mode 100644 index 00000000..1474960b --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/tools/contract/ToolCapability.kt @@ -0,0 +1,21 @@ +package com.correx.core.tools.contract + +import kotlinx.serialization.Serializable + +/** + * What a tool is allowed to do, independent of its name. Plane-2 (`core:toolintent`) rules dispatch + * on these — never on tool names — and they are recorded on + * [com.correx.core.events.events.ToolInvocationRequestedEvent] at emission, so replay classifies a + * call by the capability it actually had rather than re-deriving it from the live registry. + * + * Lives in `core:events` (the lowest module) so events can carry it; `core:tools` and everything + * above import it from here — the package name is unchanged, so the move is import-transparent. + */ +@Serializable +enum class ToolCapability { + FILE_READ, + FILE_WRITE, + NETWORK_ACCESS, + SHELL_EXEC, + PROCESS_SPAWN, +} diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt index 624829e2..9d685bdf 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt @@ -648,6 +648,7 @@ abstract class SessionOrchestrator( toolName = toolCall.function.name, tier = tier, request = request, + capabilities = tool?.requiredCapabilities ?: emptySet(), ), ) if (toolCall.function.name != STAGE_COMPLETE_TOOL && diff --git a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/ReadFilesProjection.kt b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/ReadFilesProjection.kt index 5c3af946..16ee828f 100644 --- a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/ReadFilesProjection.kt +++ b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/ReadFilesProjection.kt @@ -6,6 +6,7 @@ import com.correx.core.events.events.ToolInvocationRequestedEvent import com.correx.core.events.types.SessionId import com.correx.core.sessions.projections.Projection import com.correx.core.toolintent.rules.candidatePathStrings +import com.correx.core.tools.contract.ToolCapability /** Reads still awaiting their completion event, plus the paths confirmed read so far. */ data class ReadFilesState( @@ -14,11 +15,12 @@ data class ReadFilesState( ) /** - * Folds one session's tool events into the set of file paths it has successfully read. A `file_read` - * counts only once its [ToolExecutionCompletedEvent] lands — a read that failed (file not found) - * never enters the set, so it can't satisfy [com.correx.core.toolintent.rules.ReadBeforeWriteRule]. - * Paths are kept as the raw arguments; the rule resolves both sides through the probe, so - * `./Foo.kt` read and `Foo.kt` written still match. The same projection precedent as + * Folds one session's tool events into the set of file paths it has successfully read. A call counts + * as a read by its recorded [ToolCapability.FILE_READ] capability (not its tool name), and only once + * its [ToolExecutionCompletedEvent] lands — a read that failed (file not found) never enters the + * set, so it can't satisfy [com.correx.core.toolintent.rules.ReadBeforeWriteRule]. Paths are kept as + * the raw arguments; the rule resolves both sides through the probe, so `./Foo.kt` read and + * `Foo.kt` written still match. Same projection precedent as * [com.correx.core.sessions.projections.EgressAllowlistProjection]. */ class ReadFilesProjection(private val sessionId: SessionId) : Projection { @@ -33,7 +35,7 @@ class ReadFilesProjection(private val sessionId: SessionId) : Projection Date: Wed, 24 Jun 2026 20:16:15 +0000 Subject: [PATCH 097/107] refactor(toolintent): one SessionContext read-model for the gates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the single-purpose sessionReadPaths plumbing with a SessionContext {reads, writes, activeTask} folded by SessionContextProjection over the session stream — the read-model every anti-hallucination gate consumes (plane-2 rules via ToolCallAssessmentInput.session; tool gates via a port, next). - reads: completions whose recorded capability includes FILE_READ. - writes: the resolved paths the orchestrator already records on each completion's receipt.affectedEntities (FileAffectingTool) — no param-parsing reinvention. - activeTask: null for now; populated once claim records a session fact. ReadBeforeWriteRule now reads input.session.reads; ReadFilesProjection is retired. Co-Authored-By: Claude Opus 4.8 --- .../orchestration/SessionOrchestrator.kt | 15 +++-- .../core/toolintent/ReadFilesProjection.kt | 52 ---------------- .../correx/core/toolintent/SessionContext.kt | 62 +++++++++++++++++++ .../correx/core/toolintent/ToolCallRule.kt | 8 +-- .../toolintent/rules/ReadBeforeWriteRule.kt | 2 +- .../toolintent/ReadBeforeWriteRuleTest.kt | 2 +- ...est.kt => SessionContextProjectionTest.kt} | 34 +++++----- 7 files changed, 89 insertions(+), 86 deletions(-) delete mode 100644 core/toolintent/src/main/kotlin/com/correx/core/toolintent/ReadFilesProjection.kt create mode 100644 core/toolintent/src/main/kotlin/com/correx/core/toolintent/SessionContext.kt rename core/toolintent/src/test/kotlin/com/correx/core/toolintent/{ReadFilesProjectionTest.kt => SessionContextProjectionTest.kt} (67%) diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt index 9d685bdf..c9b0826d 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt @@ -69,7 +69,7 @@ import com.correx.core.events.events.ToolReceipt import com.correx.core.events.events.ToolRequest import com.correx.core.events.risk.RiskAction import com.correx.core.events.risk.RiskSummary -import com.correx.core.toolintent.ReadFilesProjection +import com.correx.core.toolintent.SessionContextProjection import com.correx.core.toolintent.ToolCallAssessmentInput import com.correx.core.toolintent.ToolCallAssessor import com.correx.core.toolintent.WorkspacePolicy @@ -970,7 +970,7 @@ abstract class SessionOrchestrator( paramRoles = tool?.paramRoles ?: emptyMap(), writeManifest = writeManifest, sessionEgressHosts = sessionEgressHosts, - sessionReadPaths = resolveSessionReadPaths(sessionId), + session = resolveSessionContext(sessionId), ), ) emit( @@ -1001,15 +1001,14 @@ abstract class SessionOrchestrator( } /** - * Folds this session's file_read tool events through [ReadFilesProjection] into the set of paths - * it has successfully read — the input to [com.correx.core.toolintent.rules.ReadBeforeWriteRule]. - * Replay-safe (reads the log, never live state); empty before any read completes. + * Folds this session's tool events through [SessionContextProjection] into the read-model the + * anti-hallucination gates consume (reads, writes, active task). Replay-safe (reads the log, + * never live state); empty before anything has happened. */ - internal fun resolveSessionReadPaths(sessionId: SessionId): Set { - val projection = ReadFilesProjection(sessionId) + internal fun resolveSessionContext(sessionId: SessionId): com.correx.core.toolintent.SessionContext { + val projection = SessionContextProjection(sessionId) return eventStore.read(sessionId) .fold(projection.initial()) { acc, event -> projection.apply(acc, event) } - .readPaths } private suspend fun buildSchemaEntries( diff --git a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/ReadFilesProjection.kt b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/ReadFilesProjection.kt deleted file mode 100644 index 16ee828f..00000000 --- a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/ReadFilesProjection.kt +++ /dev/null @@ -1,52 +0,0 @@ -package com.correx.core.toolintent - -import com.correx.core.events.events.StoredEvent -import com.correx.core.events.events.ToolExecutionCompletedEvent -import com.correx.core.events.events.ToolInvocationRequestedEvent -import com.correx.core.events.types.SessionId -import com.correx.core.sessions.projections.Projection -import com.correx.core.toolintent.rules.candidatePathStrings -import com.correx.core.tools.contract.ToolCapability - -/** Reads still awaiting their completion event, plus the paths confirmed read so far. */ -data class ReadFilesState( - val pending: Map> = emptyMap(), - val readPaths: Set = emptySet(), -) - -/** - * Folds one session's tool events into the set of file paths it has successfully read. A call counts - * as a read by its recorded [ToolCapability.FILE_READ] capability (not its tool name), and only once - * its [ToolExecutionCompletedEvent] lands — a read that failed (file not found) never enters the - * set, so it can't satisfy [com.correx.core.toolintent.rules.ReadBeforeWriteRule]. Paths are kept as - * the raw arguments; the rule resolves both sides through the probe, so `./Foo.kt` read and - * `Foo.kt` written still match. Same projection precedent as - * [com.correx.core.sessions.projections.EgressAllowlistProjection]. - */ -class ReadFilesProjection(private val sessionId: SessionId) : Projection { - - override fun initial(): ReadFilesState = ReadFilesState() - - override fun apply(state: ReadFilesState, event: StoredEvent): ReadFilesState = - when (val payload = event.payload) { - is ToolInvocationRequestedEvent -> recordPending(state, payload) - is ToolExecutionCompletedEvent -> confirmRead(state, payload) - else -> state - } - - private fun recordPending(state: ReadFilesState, payload: ToolInvocationRequestedEvent): ReadFilesState { - if (payload.sessionId != sessionId || ToolCapability.FILE_READ !in payload.capabilities) return state - val paths = candidatePathStrings(emptyMap(), payload.request.parameters) - if (paths.isEmpty()) return state - return state.copy(pending = state.pending + (payload.invocationId.value to paths)) - } - - private fun confirmRead(state: ReadFilesState, payload: ToolExecutionCompletedEvent): ReadFilesState { - val paths = state.pending[payload.invocationId.value] - if (payload.sessionId != sessionId || paths == null) return state - return state.copy( - pending = state.pending - payload.invocationId.value, - readPaths = state.readPaths + paths, - ) - } -} diff --git a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/SessionContext.kt b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/SessionContext.kt new file mode 100644 index 00000000..d7127a00 --- /dev/null +++ b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/SessionContext.kt @@ -0,0 +1,62 @@ +package com.correx.core.toolintent + +import com.correx.core.events.events.StoredEvent +import com.correx.core.events.events.ToolExecutionCompletedEvent +import com.correx.core.events.events.ToolInvocationRequestedEvent +import com.correx.core.events.types.SessionId +import com.correx.core.sessions.projections.Projection +import com.correx.core.toolintent.rules.candidatePathStrings +import com.correx.core.tools.contract.ToolCapability + +/** + * What a session has done to the workspace this run, plus the task it is working — the single + * read-model every anti-hallucination gate consumes (plane-2 rules via the threaded + * [ToolCallAssessmentInput.session]; tool gates via a provider port). [pendingReads] is + * fold-internal (reads awaiting their completion event) and not meant for consumers. + */ +data class SessionContext( + val reads: Set = emptySet(), + val writes: Set = emptySet(), + val activeTask: ActiveTask? = null, + val pendingReads: Map> = emptyMap(), +) + +/** The task this session has claimed and is working, with its declared write scope. */ +data class ActiveTask(val taskId: String, val scope: List) + +/** + * Folds one session's tool events into a [SessionContext]: + * - **reads** — completed invocations whose recorded capability includes FILE_READ (path from the + * request args; a read that failed never completes, so it never counts). + * - **writes** — the resolved paths the orchestrator already records on each completion's + * `receipt.affectedEntities` (via FileAffectingTool), so writes need no param-parsing here. + * - **activeTask** — set when the session claims a task; null until a SessionWorkingTaskEvent lands. + */ +class SessionContextProjection(private val sessionId: SessionId) : Projection { + + override fun initial(): SessionContext = SessionContext() + + override fun apply(state: SessionContext, event: StoredEvent): SessionContext = + when (val payload = event.payload) { + is ToolInvocationRequestedEvent -> recordPendingRead(state, payload) + is ToolExecutionCompletedEvent -> recordCompletion(state, payload) + else -> state + } + + private fun recordPendingRead(state: SessionContext, payload: ToolInvocationRequestedEvent): SessionContext { + if (payload.sessionId != sessionId || ToolCapability.FILE_READ !in payload.capabilities) return state + val paths = candidatePathStrings(emptyMap(), payload.request.parameters) + if (paths.isEmpty()) return state + return state.copy(pendingReads = state.pendingReads + (payload.invocationId.value to paths)) + } + + private fun recordCompletion(state: SessionContext, payload: ToolExecutionCompletedEvent): SessionContext { + if (payload.sessionId != sessionId) return state + val justRead = state.pendingReads[payload.invocationId.value] + return state.copy( + reads = if (justRead != null) state.reads + justRead else state.reads, + writes = state.writes + payload.receipt.affectedEntities, + pendingReads = state.pendingReads - payload.invocationId.value, + ) + } +} diff --git a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/ToolCallRule.kt b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/ToolCallRule.kt index 719e32e0..79cb4777 100644 --- a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/ToolCallRule.kt +++ b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/ToolCallRule.kt @@ -29,10 +29,10 @@ data class ToolCallAssessmentInput( // EgressAllowlistProjection). Unioned with the static allow-list at the network gate; empty // means "no per-session grants", which never narrows the static allow-list. val sessionEgressHosts: Set = emptySet(), - // Paths this session has successfully file_read (folded via ReadFilesProjection). The input to - // ReadBeforeWriteRule; empty means nothing has been read yet (so any write to an existing file - // is unread). Raw argument strings — the rule resolves both sides through the probe. - val sessionReadPaths: Set = emptySet(), + // What this session has done this run (reads, writes) and the task it's working — folded via + // SessionContextProjection. The input to the anti-hallucination rules (read-before-write reads + // .reads, write-scope reads .activeTask). Empty by default so unrelated rules ignore it. + val session: SessionContext = SessionContext(), ) data class ToolCallAssessment( diff --git a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/ReadBeforeWriteRule.kt b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/ReadBeforeWriteRule.kt index 57492985..53a81ed1 100644 --- a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/ReadBeforeWriteRule.kt +++ b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/ReadBeforeWriteRule.kt @@ -28,7 +28,7 @@ class ReadBeforeWriteRule : ToolCallRule { override fun assess(input: ToolCallAssessmentInput): ToolCallAssessment { val root = input.workspace.workspaceRoot - val readReal = input.sessionReadPaths.map { realOf(input, root, it) }.toSet() + val readReal = input.session.reads.map { realOf(input, root, it) }.toSet() val issues = mutableListOf() val observations = mutableListOf() diff --git a/core/toolintent/src/test/kotlin/com/correx/core/toolintent/ReadBeforeWriteRuleTest.kt b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/ReadBeforeWriteRuleTest.kt index fe84835f..76c8e1ee 100644 --- a/core/toolintent/src/test/kotlin/com/correx/core/toolintent/ReadBeforeWriteRuleTest.kt +++ b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/ReadBeforeWriteRuleTest.kt @@ -36,7 +36,7 @@ class ReadBeforeWriteRuleTest { capabilities = setOf(ToolCapability.FILE_WRITE), workspace = WorkspacePolicy(workspace, emptyList()), probe = probe, - sessionReadPaths = read, + session = SessionContext(reads = read), ) @Test diff --git a/core/toolintent/src/test/kotlin/com/correx/core/toolintent/ReadFilesProjectionTest.kt b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/SessionContextProjectionTest.kt similarity index 67% rename from core/toolintent/src/test/kotlin/com/correx/core/toolintent/ReadFilesProjectionTest.kt rename to core/toolintent/src/test/kotlin/com/correx/core/toolintent/SessionContextProjectionTest.kt index 596676bf..93e870ed 100644 --- a/core/toolintent/src/test/kotlin/com/correx/core/toolintent/ReadFilesProjectionTest.kt +++ b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/SessionContextProjectionTest.kt @@ -18,14 +18,14 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue -class ReadFilesProjectionTest { +class SessionContextProjectionTest { private val session = SessionId("s") - private val projection = ReadFilesProjection(session) + private val projection = SessionContextProjection(session) private var seq = 0L private fun stored(payload: EventPayload) = StoredEvent( - metadata = EventMetadata(EventId("e-${seq}"), session, Instant.parse("2026-01-01T00:00:00Z"), 1, null, null), + metadata = EventMetadata(EventId("e-$seq"), session, Instant.parse("2026-01-01T00:00:00Z"), 1, null, null), sequence = ++seq, sessionSequence = seq, payload = payload, @@ -43,16 +43,17 @@ class ReadFilesProjectionTest { ), ) - private fun completed(invId: String, tool: String = "file_read") = stored( + private fun completed(invId: String, affected: List = emptyList()) = stored( ToolExecutionCompletedEvent( invocationId = ToolInvocationId(invId), sessionId = session, - toolName = tool, + toolName = "tool", receipt = ToolReceipt( invocationId = ToolInvocationId(invId), - toolName = tool, + toolName = "tool", exitCode = 0, outputSummary = "ok", + affectedEntities = affected, durationMs = 1, tier = Tier.T1, timestamp = Instant.parse("2026-01-01T00:00:00Z"), @@ -64,22 +65,15 @@ class ReadFilesProjectionTest { events.fold(projection.initial()) { acc, e -> projection.apply(acc, e) } @Test - fun `a completed file_read adds the path to the read set`() { - val state = fold(listOf(readRequested("r1", "src/A.kt"), completed("r1"))) - assertEquals(setOf("src/A.kt"), state.readPaths) + fun `a completed read enters reads but a pending one does not`() { + assertEquals(setOf("src/A.kt"), fold(listOf(readRequested("r1", "src/A.kt"), completed("r1"))).reads) + assertTrue(fold(listOf(readRequested("r1", "src/A.kt"))).reads.isEmpty()) } @Test - fun `a read still pending its completion does not count`() { - val state = fold(listOf(readRequested("r1", "src/A.kt"))) - assertTrue(state.readPaths.isEmpty()) - assertTrue(state.pending.containsKey("r1")) - } - - @Test - fun `a non-read tool completion is ignored`() { - // file_write request never enters pending, so its completion adds nothing. - val state = fold(listOf(completed("w1", tool = "file_write"))) - assertTrue(state.readPaths.isEmpty()) + fun `writes come from the completion's affectedEntities`() { + val ctx = fold(listOf(completed("w1", affected = listOf("core/A.kt", "core/B.kt")))) + assertEquals(setOf("core/A.kt", "core/B.kt"), ctx.writes) + assertTrue(ctx.reads.isEmpty()) } } From 30321e08a580de6ef3c58c909cd63c0eb94a8017 Mon Sep 17 00:00:00 2001 From: kami Date: Wed, 24 Jun 2026 20:35:40 +0000 Subject: [PATCH 098/107] feat(toolintent): record the session's active task as a session-local fact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claiming a task now emits SessionWorkingTaskEvent{taskId, affectedPaths} into the session's own stream (via a SessionFactRecorder port + event-store adapter), and SessionContextProjection folds it into SessionContext.activeTask. So a gate learns "which task is this session working, and its scope" by reading the session stream — no cross-stream scan, no kernel->tasks dependency. Re-emitted on an affected_paths edit by the claimant, so the snapshot scope stays current; latest wins on replay. This is the data source the write-scope gate needs (next). Co-Authored-By: Claude Opus 4.8 --- .../kotlin/com/correx/apps/server/Main.kt | 1 + .../tasks/EventStoreSessionFactRecorder.kt | 36 +++++++++++++++++++ .../core/events/events/SessionEvents.kt | 15 ++++++++ .../events/serialization/Serialization.kt | 2 ++ .../correx/core/toolintent/SessionContext.kt | 7 ++++ .../SessionContextProjectionTest.kt | 14 ++++++++ .../tools/task/SessionFactRecorder.kt | 14 ++++++++ .../infrastructure/tools/task/TaskTools.kt | 3 +- .../tools/task/TaskUpdateTool.kt | 23 +++++++++++- .../tools/task/TaskToolsTest.kt | 18 ++++++++++ 10 files changed, 131 insertions(+), 2 deletions(-) create mode 100644 apps/server/src/main/kotlin/com/correx/apps/server/tasks/EventStoreSessionFactRecorder.kt create mode 100644 infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/SessionFactRecorder.kt diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt index 93621ec2..8bf9a944 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt @@ -238,6 +238,7 @@ fun main() { taskDocumentResolver, taskArtifactResolver, taskSessionResolver, + com.correx.apps.server.tasks.EventStoreSessionFactRecorder(eventStore), ) val toolRegistry = InfrastructureModule.createToolRegistry( buildToolConfig( diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/tasks/EventStoreSessionFactRecorder.kt b/apps/server/src/main/kotlin/com/correx/apps/server/tasks/EventStoreSessionFactRecorder.kt new file mode 100644 index 00000000..6a16a92e --- /dev/null +++ b/apps/server/src/main/kotlin/com/correx/apps/server/tasks/EventStoreSessionFactRecorder.kt @@ -0,0 +1,36 @@ +package com.correx.apps.server.tasks + +import com.correx.core.events.events.EventMetadata +import com.correx.core.events.events.NewEvent +import com.correx.core.events.events.SessionWorkingTaskEvent +import com.correx.core.events.stores.EventStore +import com.correx.core.events.types.EventId +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.TaskId +import com.correx.infrastructure.tools.task.SessionFactRecorder +import kotlinx.datetime.Clock +import java.util.UUID + +/** + * Adapter for [SessionFactRecorder]: appends a [SessionWorkingTaskEvent] to the session's own stream + * when it claims (or re-scopes) a task, so [com.correx.core.toolintent.SessionContextProjection] + * folds it into the active task without scanning task streams. + */ +class EventStoreSessionFactRecorder(private val eventStore: EventStore) : SessionFactRecorder { + + override suspend fun recordWorkingTask(sessionId: SessionId, taskId: TaskId, affectedPaths: List) { + eventStore.append( + NewEvent( + metadata = EventMetadata( + eventId = EventId(UUID.randomUUID().toString()), + sessionId = sessionId, + timestamp = Clock.System.now(), + schemaVersion = 1, + causationId = null, + correlationId = null, + ), + payload = SessionWorkingTaskEvent(sessionId, taskId, affectedPaths), + ), + ) + } +} diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/SessionEvents.kt b/core/events/src/main/kotlin/com/correx/core/events/events/SessionEvents.kt index 5dbefe81..86087dcd 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/events/SessionEvents.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/events/SessionEvents.kt @@ -1,6 +1,7 @@ package com.correx.core.events.events import com.correx.core.events.types.SessionId +import com.correx.core.events.types.TaskId import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -10,6 +11,20 @@ data class ChatSessionStartedEvent( val sessionId: SessionId, ) : EventPayload +/** + * Records, in the session's own stream, that this run claimed [taskId] and is working it with the + * declared [affectedPaths] write scope — a session-local fact so the gates know the active task + * without scanning task streams. Re-emitted when the claimant edits affected_paths, so the folded + * scope stays current; SessionContextProjection takes the most recent. + */ +@Serializable +@SerialName("SessionWorkingTask") +data class SessionWorkingTaskEvent( + val sessionId: SessionId, + val taskId: TaskId, + val affectedPaths: List = emptyList(), +) : EventPayload + @Serializable @SerialName("SessionWorkspaceBound") data class SessionWorkspaceBoundEvent( diff --git a/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt b/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt index 76667f4d..e30f4f27 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt @@ -12,6 +12,7 @@ import com.correx.core.events.events.ArtifactValidatingEvent import com.correx.core.events.events.BriefEchoMismatchEvent import com.correx.core.events.events.BriefGroundingCheckedEvent import com.correx.core.events.events.ChatSessionStartedEvent +import com.correx.core.events.events.SessionWorkingTaskEvent import com.correx.core.events.events.ClarificationAnsweredEvent import com.correx.core.events.events.ClarificationRequestedEvent import com.correx.core.events.events.ChatTurnEvent @@ -139,6 +140,7 @@ val eventModule = SerializersModule { subclass(BriefEchoMismatchEvent::class) subclass(RiskAssessedEvent::class) subclass(ChatSessionStartedEvent::class) + subclass(SessionWorkingTaskEvent::class) subclass(ChatTurnEvent::class) subclass(RouterNarrationEvent::class) subclass(SessionWorkspaceBoundEvent::class) diff --git a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/SessionContext.kt b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/SessionContext.kt index d7127a00..599f2dfd 100644 --- a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/SessionContext.kt +++ b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/SessionContext.kt @@ -1,5 +1,6 @@ package com.correx.core.toolintent +import com.correx.core.events.events.SessionWorkingTaskEvent import com.correx.core.events.events.StoredEvent import com.correx.core.events.events.ToolExecutionCompletedEvent import com.correx.core.events.events.ToolInvocationRequestedEvent @@ -40,6 +41,12 @@ class SessionContextProjection(private val sessionId: SessionId) : Projection recordPendingRead(state, payload) is ToolExecutionCompletedEvent -> recordCompletion(state, payload) + is SessionWorkingTaskEvent -> + if (payload.sessionId == sessionId) { + state.copy(activeTask = ActiveTask(payload.taskId.value, payload.affectedPaths)) + } else { + state + } else -> state } diff --git a/core/toolintent/src/test/kotlin/com/correx/core/toolintent/SessionContextProjectionTest.kt b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/SessionContextProjectionTest.kt index 93e870ed..0f1053a7 100644 --- a/core/toolintent/src/test/kotlin/com/correx/core/toolintent/SessionContextProjectionTest.kt +++ b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/SessionContextProjectionTest.kt @@ -6,9 +6,11 @@ import com.correx.core.events.events.EventPayload import com.correx.core.events.events.StoredEvent import com.correx.core.events.events.ToolExecutionCompletedEvent import com.correx.core.events.events.ToolInvocationRequestedEvent +import com.correx.core.events.events.SessionWorkingTaskEvent import com.correx.core.events.events.ToolReceipt import com.correx.core.events.events.ToolRequest import com.correx.core.events.types.EventId +import com.correx.core.events.types.TaskId import com.correx.core.events.types.SessionId import com.correx.core.events.types.StageId import com.correx.core.events.types.ToolInvocationId @@ -76,4 +78,16 @@ class SessionContextProjectionTest { assertEquals(setOf("core/A.kt", "core/B.kt"), ctx.writes) assertTrue(ctx.reads.isEmpty()) } + + @Test + fun `a SessionWorkingTask event sets the active task and the latest wins`() { + val ctx = fold( + listOf( + stored(SessionWorkingTaskEvent(session, TaskId("auth-1"), listOf("core/a/**"))), + stored(SessionWorkingTaskEvent(session, TaskId("auth-2"), listOf("core/auth/**"))), + ), + ) + assertEquals("auth-2", ctx.activeTask?.taskId) + assertEquals(listOf("core/auth/**"), ctx.activeTask?.scope) + } } diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/SessionFactRecorder.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/SessionFactRecorder.kt new file mode 100644 index 00000000..ab64119b --- /dev/null +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/SessionFactRecorder.kt @@ -0,0 +1,14 @@ +package com.correx.infrastructure.tools.task + +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.TaskId + +/** + * Port: record that a session is working a task — emitted on claim (and on an affected_paths edit by + * the claimant) so the gates can fold it into SessionContext.activeTask without scanning task + * streams. The host supplies an adapter that appends a SessionWorkingTaskEvent to the session + * stream. A null binding means no recording (the bare tool still works). + */ +fun interface SessionFactRecorder { + suspend fun recordWorkingTask(sessionId: SessionId, taskId: TaskId, affectedPaths: List) +} diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskTools.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskTools.kt index ee93d5e7..e7460d96 100644 --- a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskTools.kt +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskTools.kt @@ -21,10 +21,11 @@ object TaskTools { documentResolver: TaskDocumentResolver? = null, artifactResolver: TaskArtifactResolver? = null, sessionResolver: TaskSessionResolver? = null, + sessionFacts: SessionFactRecorder? = null, ): List = listOf( TaskCreateTool(service), - TaskUpdateTool(service), + TaskUpdateTool(service, sessionFacts), TaskDeleteTool(service), TaskSearchTool(service), TaskReadyTool(service), diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt index bc9a5677..2c6f4c26 100644 --- a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt @@ -25,7 +25,10 @@ import kotlinx.serialization.json.putJsonObject * Agent-facing tool: update an existing task — edit fields, transition status, add a work-graph * link, or attach a note. The task's project is derived from its id, so only the id is required. */ -class TaskUpdateTool(private val service: TaskService) : Tool, ToolExecutor { +class TaskUpdateTool( + private val service: TaskService, + private val sessionFacts: SessionFactRecorder? = null, +) : Tool, ToolExecutor { override val name: String = "task_update" override val description: String = @@ -110,6 +113,8 @@ class TaskUpdateTool(private val service: TaskService) : Tool, ToolExecutor { request.stringParam("note")?.let { service.addNote(taskId, TaskNoteAuthor.AGENT, it) } + recordWorkingTask(request, taskId, action) + val status = service.getTask(taskId)?.state?.status?.name ?: "UNKNOWN" val warning = if (action == "claim") claimWarning(taskId) else "" return ToolResult.Success( @@ -128,6 +133,22 @@ class TaskUpdateTool(private val service: TaskService) : Tool, ToolExecutor { return fail(request, "Cannot claim ${taskId.value} — unmet blockers: $listed. Complete them, or pass force=true.") } + /** + * Record the session→task working relationship (for SessionContext.activeTask) on a claim, or on + * an affected_paths edit by the recorded claimant (to refresh the snapshot scope). No-op without + * a recorder bound. + */ + private suspend fun recordWorkingTask(request: ToolRequest, taskId: TaskId, action: String?) { + val recorder = sessionFacts ?: return + val task = service.getTask(taskId) ?: return + val isClaim = action == "claim" + val scopeEditByClaimant = request.parameters.containsKey("affected_paths") && + task.state.claimant == request.sessionId.value + if (isClaim || scopeEditByClaimant) { + recorder.recordWorkingTask(request.sessionId, taskId, task.state.affectedPaths) + } + } + /** When a force-claimed task still has unmet blockers, flag it so the agent knows it jumped a dependency. */ private fun claimWarning(taskId: TaskId): String { val blockers = service.blockers(taskId) diff --git a/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt b/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt index 20abf70a..8a5b5123 100644 --- a/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt +++ b/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt @@ -210,6 +210,24 @@ class TaskToolsTest { assertEquals(TaskStatus.IN_PROGRESS, service.getTask(TaskId("auth-2"))!!.state.status) } + @Test + fun `claiming a task records the session working it`() = runBlocking { + create.execute( + request( + "task_create", + mapOf("project" to "auth", "title" to "A", "goal" to "g", "affected_paths" to listOf("core/auth/**")), + ), + ) + val captured = mutableListOf>>() + val recorder = SessionFactRecorder { sid, tid, paths -> captured.add(Triple(sid.value, tid.value, paths)) } + + TaskUpdateTool(service, recorder).execute(request("task_update", mapOf("id" to "auth-1", "action" to "claim"))) + + assertEquals(1, captured.size) + assertEquals("auth-1", captured.single().second) + assertEquals(listOf("core/auth/**"), captured.single().third) + } + @Test fun `task_delete tombstones the task`() = runBlocking { val id = createTask() From 6b5a758081a17cc50b9a7cc96c6d7b79d82cb6ec Mon Sep 17 00:00:00 2001 From: kami Date: Wed, 24 Jun 2026 21:31:08 +0000 Subject: [PATCH 099/107] feat(tasks): cite-before-claim gate on completion Completing a task that declared affected_paths but saw no matching write this session is now blocked ("marked done without doing it"), escapable with force. TaskUpdateTool reads the session's writes through a SessionWrites port whose adapter folds SessionContext (writes = recorded receipt.affectedEntities), and matches them against the task's affected_paths globs. Co-Authored-By: Claude Opus 4.8 --- .../kotlin/com/correx/apps/server/Main.kt | 3 ++ .../server/tasks/EventStoreSessionWrites.kt | 21 +++++++++++ .../tools/task/SessionWrites.kt | 13 +++++++ .../infrastructure/tools/task/TaskTools.kt | 3 +- .../tools/task/TaskUpdateTool.kt | 37 +++++++++++++++++++ .../tools/task/TaskToolsTest.kt | 25 +++++++++++++ 6 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/main/kotlin/com/correx/apps/server/tasks/EventStoreSessionWrites.kt create mode 100644 infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/SessionWrites.kt diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt index 8bf9a944..38190c3b 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt @@ -63,6 +63,7 @@ import com.correx.core.toolintent.rules.NetworkHostRule import com.correx.core.toolintent.rules.PathContainmentRule import com.correx.core.toolintent.rules.ReadBeforeWriteRule import com.correx.core.toolintent.rules.ReferenceExistsRule +import com.correx.core.toolintent.rules.WriteScopeRule import com.correx.apps.server.freestyle.FreestyleDriver import com.correx.apps.server.inference.summarizeWithInference import com.correx.core.kernel.orchestration.JournalCompactionService @@ -239,6 +240,7 @@ fun main() { taskArtifactResolver, taskSessionResolver, com.correx.apps.server.tasks.EventStoreSessionFactRecorder(eventStore), + com.correx.apps.server.tasks.EventStoreSessionWrites(eventStore), ) val toolRegistry = InfrastructureModule.createToolRegistry( buildToolConfig( @@ -266,6 +268,7 @@ fun main() { PathContainmentRule(), ReadBeforeWriteRule(), ReferenceExistsRule(), + WriteScopeRule(), ManifestContainmentRule(), ExecInterpreterRule(toolsConfig.interpreterExecutables.toSet()), NetworkHostRule( diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/tasks/EventStoreSessionWrites.kt b/apps/server/src/main/kotlin/com/correx/apps/server/tasks/EventStoreSessionWrites.kt new file mode 100644 index 00000000..d6d020ea --- /dev/null +++ b/apps/server/src/main/kotlin/com/correx/apps/server/tasks/EventStoreSessionWrites.kt @@ -0,0 +1,21 @@ +package com.correx.apps.server.tasks + +import com.correx.core.events.stores.EventStore +import com.correx.core.events.types.SessionId +import com.correx.core.toolintent.SessionContextProjection +import com.correx.infrastructure.tools.task.SessionWrites + +/** + * Adapter for the [SessionWrites] port: folds a session's events through + * [SessionContextProjection] and returns its writes (the recorded receipt.affectedEntities). + * Powers the cite-before-claim gate. Replay-safe (reads the log, never live state). + */ +class EventStoreSessionWrites(private val eventStore: EventStore) : SessionWrites { + + override fun pathsWritten(sessionId: SessionId): Set { + val projection = SessionContextProjection(sessionId) + return eventStore.read(sessionId) + .fold(projection.initial()) { acc, event -> projection.apply(acc, event) } + .writes + } +} diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/SessionWrites.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/SessionWrites.kt new file mode 100644 index 00000000..6d82e833 --- /dev/null +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/SessionWrites.kt @@ -0,0 +1,13 @@ +package com.correx.infrastructure.tools.task + +import com.correx.core.events.types.SessionId + +/** + * Port: the paths a session has written this run. The host supplies an adapter that folds the + * session's SessionContext (its writes come from the recorded receipt.affectedEntities). The + * cite-before-claim gate in [TaskUpdateTool] uses it to confirm a task being completed actually saw + * changes under its affected_paths. A null binding leaves the gate off. + */ +fun interface SessionWrites { + fun pathsWritten(sessionId: SessionId): Set +} diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskTools.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskTools.kt index e7460d96..e3103af5 100644 --- a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskTools.kt +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskTools.kt @@ -22,10 +22,11 @@ object TaskTools { artifactResolver: TaskArtifactResolver? = null, sessionResolver: TaskSessionResolver? = null, sessionFacts: SessionFactRecorder? = null, + sessionWrites: SessionWrites? = null, ): List = listOf( TaskCreateTool(service), - TaskUpdateTool(service, sessionFacts), + TaskUpdateTool(service, sessionFacts, sessionWrites), TaskDeleteTool(service), TaskSearchTool(service), TaskReadyTool(service), diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt index 2c6f4c26..571cb1d2 100644 --- a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt @@ -20,14 +20,21 @@ import kotlinx.serialization.json.buildJsonArray import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put import kotlinx.serialization.json.putJsonObject +import java.nio.file.FileSystems +import java.nio.file.Path /** * Agent-facing tool: update an existing task — edit fields, transition status, add a work-graph * link, or attach a note. The task's project is derived from its id, so only the id is required. + * + * [sessionFacts] records the session's active task on claim; [sessionWrites], when bound, powers + * the cite-before-claim gate (completing a task that declared affected_paths but saw no matching + * write this session is blocked, escapable with force). */ class TaskUpdateTool( private val service: TaskService, private val sessionFacts: SessionFactRecorder? = null, + private val sessionWrites: SessionWrites? = null, ) : Tool, ToolExecutor { override val name: String = "task_update" @@ -95,6 +102,7 @@ class TaskUpdateTool( val action = request.stringParam("action") claimBlock(request, taskId, action)?.let { return it } + completeBlock(request, taskId, action)?.let { return it } applyFieldEdits(request, taskId) @@ -133,6 +141,35 @@ class TaskUpdateTool( return fail(request, "Cannot claim ${taskId.value} — unmet blockers: $listed. Complete them, or pass force=true.") } + /** + * Cite-before-claim: refuse to complete a task that declared affected_paths but saw no matching + * write this session — "marked done without doing it." Off when no [sessionWrites] is bound or + * the task declared no scope; escapable with force. + */ + private fun completeBlock(request: ToolRequest, taskId: TaskId, action: String?): ToolResult.Failure? { + if (action != "complete" || request.boolParam("force")) return null + val writes = sessionWrites ?: return null + val scope = service.getTask(taskId)?.state?.affectedPaths.orEmpty() + if (scope.isEmpty()) return null + if (matchesAnyGlob(writes.pathsWritten(request.sessionId), scope)) return null + return fail( + request, + "Cannot complete ${taskId.value} — no changes under its affected_paths ($scope) this " + + "session. Make the change, or pass force=true.", + ) + } + + /** True if any written path matches any affected_paths glob (workspace-relative; './' tolerated). */ + private fun matchesAnyGlob(written: Set, globs: List): Boolean { + if (written.isEmpty()) return false + val fs = FileSystems.getDefault() + val matchers = globs.map { fs.getPathMatcher("glob:${it.removePrefix("./")}") } + return written.any { w -> + val rel = Path.of(w.removePrefix("./")) + matchers.any { it.matches(rel) } + } + } + /** * Record the session→task working relationship (for SessionContext.activeTask) on a claim, or on * an affected_paths edit by the recorded claimant (to refresh the snapshot scope). No-op without diff --git a/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt b/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt index 8a5b5123..e6b395eb 100644 --- a/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt +++ b/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt @@ -228,6 +228,31 @@ class TaskToolsTest { assertEquals(listOf("core/auth/**"), captured.single().third) } + @Test + fun `task_update blocks completing a task with no writes under its affected_paths`() = runBlocking { + create.execute( + request( + "task_create", + mapOf("project" to "auth", "title" to "A", "goal" to "g", "affected_paths" to listOf("core/auth/**")), + ), + ) + val id = "auth-1" + update.execute(request("task_update", mapOf("id" to id, "action" to "claim"))) + update.execute(request("task_update", mapOf("id" to id, "action" to "submit_for_review"))) + + // No matching write this session → complete is blocked, task stays IN_REVIEW. + val noWrites = TaskUpdateTool(service, sessionWrites = SessionWrites { emptySet() }) + val blocked = noWrites.execute(request("task_update", mapOf("id" to id, "action" to "complete"))) + assertTrue(blocked is ToolResult.Failure) + assertEquals(TaskStatus.IN_REVIEW, service.getTask(TaskId(id))!!.state.status) + + // A write under affected_paths → completes. + val withWrites = TaskUpdateTool(service, sessionWrites = SessionWrites { setOf("core/auth/Login.kt") }) + val ok = withWrites.execute(request("task_update", mapOf("id" to id, "action" to "complete"))) + assertTrue(ok is ToolResult.Success) + assertEquals(TaskStatus.DONE, service.getTask(TaskId(id))!!.state.status) + } + @Test fun `task_delete tombstones the task`() = runBlocking { val id = createTask() From 415936169057c832f8dab31aa3c92b232da45595 Mon Sep 17 00:00:00 2001 From: kami Date: Wed, 24 Jun 2026 21:31:08 +0000 Subject: [PATCH 100/107] feat(toolintent): write-scope adherence gate (declarable) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a session has claimed a task with declared affected_paths, an in-workspace write outside that scope is BLOCKED — but declarably: the message tells the agent to widen the task's affected_paths via task_update (which re-records the scope) and retry. Legitimate, unforeseen changes (a dependency, a caller) are never trapped, only forced to become a recorded, auditable scope change. Reads the session's activeTask scope (recorded at claim) — no cross-stream lookup. Co-Authored-By: Claude Opus 4.8 --- .../core/toolintent/rules/WriteScopeRule.kt | 72 +++++++++++++++++++ .../core/toolintent/WriteScopeRuleTest.kt | 55 ++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/WriteScopeRule.kt create mode 100644 core/toolintent/src/test/kotlin/com/correx/core/toolintent/WriteScopeRuleTest.kt diff --git a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/WriteScopeRule.kt b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/WriteScopeRule.kt new file mode 100644 index 00000000..85dd7438 --- /dev/null +++ b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/WriteScopeRule.kt @@ -0,0 +1,72 @@ +package com.correx.core.toolintent.rules + +import com.correx.core.events.events.ToolCallObservation +import com.correx.core.events.risk.RiskAction +import com.correx.core.toolintent.ToolCallAssessment +import com.correx.core.toolintent.ToolCallAssessmentInput +import com.correx.core.toolintent.ToolCallRule +import com.correx.core.toolintent.maxAction +import com.correx.core.tools.contract.ToolCapability +import com.correx.core.validation.model.ValidationIssue +import com.correx.core.validation.model.ValidationSeverity +import java.nio.file.FileSystems +import java.nio.file.Path + +/** + * Write-scope adherence. When the session has claimed a task that declared affected_paths, an + * in-workspace write outside that scope is BLOCKED — but **declarably**: the message tells the + * agent that if the change is genuinely needed (a dependency, a caller the plan didn't foresee) it + * should widen the task's affected_paths via task_update (which re-records the scope), then retry. + * So legitimate work is never trapped — only forced to be explicit, turning silent scope-creep into + * a recorded, auditable scope change. No active task or no declared scope ⇒ nothing to enforce; + * out-of-workspace targets are PathContainmentRule's concern. + */ +class WriteScopeRule : ToolCallRule { + + override fun appliesTo(capabilities: Set): Boolean = + ToolCapability.FILE_WRITE in capabilities + + override fun assess(input: ToolCallAssessmentInput): ToolCallAssessment { + val active = input.session.activeTask + if (active == null || active.scope.isEmpty()) return ToolCallAssessment() + + val workspaceReal = input.probe.resolveReal(input.workspace.workspaceRoot) + val matchers = active.scope.map { FileSystems.getDefault().getPathMatcher("glob:${it.removePrefix("./")}") } + + val issues = mutableListOf() + val observations = mutableListOf() + var disposition = RiskAction.PROCEED + + for (raw in candidatePathStrings(input.paramRoles, input.request.parameters)) { + val candidate = Path.of(raw) + val resolvedReal = input.probe.resolveReal( + if (candidate.isAbsolute) candidate else input.workspace.workspaceRoot.resolve(candidate), + ) + if (!resolvedReal.startsWith(workspaceReal)) continue // out of workspace: not this gate + val rel = workspaceReal.relativize(resolvedReal) + val inScope = matchers.any { it.matches(rel) } + + observations += ToolCallObservation( + ruleCode = RULE_CODE, + facts = mapOf("path" to raw, "inScope" to inScope.toString(), "task" to active.taskId), + ) + + if (!inScope) { + issues += ValidationIssue( + code = RULE_CODE, + message = "Tool '${input.request.toolName}' writes '$raw', outside claimed task " + + "${active.taskId}'s affected_paths (${active.scope}). If this change is needed, " + + "add its path via task_update affected_paths (note why), then retry.", + severity = ValidationSeverity.ERROR, + ) + disposition = maxAction(disposition, RiskAction.BLOCK) + } + } + + return ToolCallAssessment(issues = issues, observations = observations, disposition = disposition) + } + + private companion object { + const val RULE_CODE = "WRITE_SCOPE" + } +} diff --git a/core/toolintent/src/test/kotlin/com/correx/core/toolintent/WriteScopeRuleTest.kt b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/WriteScopeRuleTest.kt new file mode 100644 index 00000000..63389229 --- /dev/null +++ b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/WriteScopeRuleTest.kt @@ -0,0 +1,55 @@ +package com.correx.core.toolintent + +import com.correx.core.events.events.ToolRequest +import com.correx.core.events.risk.RiskAction +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.events.types.ToolInvocationId +import com.correx.core.toolintent.rules.WriteScopeRule +import com.correx.core.tools.contract.ToolCapability +import java.nio.file.Path +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class WriteScopeRuleTest { + + private val workspace = Path.of("/work/project") + private val rule = WriteScopeRule() + + private class FakeProbe : WorldProbe { + override fun exists(path: Path) = true + override fun resolveReal(path: Path): Path = path.toAbsolutePath().normalize() + } + + private fun input(pathArg: String, active: ActiveTask?) = ToolCallAssessmentInput( + request = ToolRequest(ToolInvocationId("i"), SessionId("s"), StageId("st"), "file_write", mapOf("path" to pathArg)), + capabilities = setOf(ToolCapability.FILE_WRITE), + workspace = WorkspacePolicy(workspace, emptyList()), + probe = FakeProbe(), + session = SessionContext(activeTask = active), + ) + + private val scoped = ActiveTask("auth-2", listOf("core/auth/**")) + + @Test + fun `no active task means nothing to enforce`() { + assertEquals(RiskAction.PROCEED, rule.assess(input("/work/project/core/billing/X.kt", active = null)).disposition) + } + + @Test + fun `a write inside the claimed task scope proceeds`() { + val r = rule.assess(input("/work/project/core/auth/Login.kt", scoped)) + assertEquals(RiskAction.PROCEED, r.disposition) + assertEquals("true", r.observations.single().facts["inScope"]) + } + + @Test + fun `a write outside the claimed task scope is blocked and names the task`() { + val r = rule.assess(input("/work/project/core/billing/Charge.kt", scoped)) + assertEquals(RiskAction.BLOCK, r.disposition) + assertEquals("WRITE_SCOPE", r.issues.single().code) + assertTrue(r.issues.single().message.contains("auth-2")) + assertTrue(r.issues.single().message.contains("task_update affected_paths")) + } +} From 6a779861c2df223fb8787d86fda52fcd764fb9ff Mon Sep 17 00:00:00 2001 From: kami Date: Thu, 25 Jun 2026 06:15:15 +0000 Subject: [PATCH 101/107] feat(toolintent): stale-write gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Block writing a file whose on-disk content changed since the session read it — a concurrent/external edit the agent's view doesn't reflect. file_read records a content hash on whole-file reads; the orchestrator's completion path now carries the tool's structuredOutput (it previously dropped it, unlike SandboxedToolExecutor — the two paths were inconsistent), so SessionContext.readHashes folds it. StaleWriteRule compares the current hash (WorldProbe.contentHash) against the read-time hash and BLOCKs a mismatch, telling the agent to re-read first. Files the session wrote itself are excluded, so its own edits never look stale; partial reads set no baseline. Co-Authored-By: Claude Opus 4.8 --- .../kotlin/com/correx/apps/server/Main.kt | 2 + .../orchestration/SessionOrchestrator.kt | 4 + .../correx/core/toolintent/SessionContext.kt | 10 +++ .../com/correx/core/toolintent/WorldProbe.kt | 11 +++ .../core/toolintent/rules/StaleWriteRule.kt | 74 +++++++++++++++++++ .../SessionContextProjectionTest.kt | 15 +++- .../core/toolintent/StaleWriteRuleTest.kt | 60 +++++++++++++++ .../tools/filesystem/FileReadTool.kt | 8 ++ 8 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/StaleWriteRule.kt create mode 100644 core/toolintent/src/test/kotlin/com/correx/core/toolintent/StaleWriteRuleTest.kt diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt index 38190c3b..9aaa5462 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt @@ -63,6 +63,7 @@ import com.correx.core.toolintent.rules.NetworkHostRule import com.correx.core.toolintent.rules.PathContainmentRule import com.correx.core.toolintent.rules.ReadBeforeWriteRule import com.correx.core.toolintent.rules.ReferenceExistsRule +import com.correx.core.toolintent.rules.StaleWriteRule import com.correx.core.toolintent.rules.WriteScopeRule import com.correx.apps.server.freestyle.FreestyleDriver import com.correx.apps.server.inference.summarizeWithInference @@ -269,6 +270,7 @@ fun main() { ReadBeforeWriteRule(), ReferenceExistsRule(), WriteScopeRule(), + StaleWriteRule(), ManifestContainmentRule(), ExecInterpreterRule(toolsConfig.interpreterExecutables.toSet()), NetworkHostRule( diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt index c9b0826d..7515962f 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt @@ -1289,6 +1289,10 @@ abstract class SessionOrchestrator( toolName = toolCall.function.name, exitCode = result.exitCode, outputSummary = result.output.take(OUTPUT_SUMMARY_LIMIT), + // Carry the tool's structured metadata (e.g. file_read's contentHash) so + // session projections can read it — matching SandboxedToolExecutor, which + // already does this; the two completion paths were inconsistent. + structuredOutput = result.metadata, affectedEntities = affected.map { it.toString() }, durationMs = 0, tier = tier, diff --git a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/SessionContext.kt b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/SessionContext.kt index 599f2dfd..efc63ed1 100644 --- a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/SessionContext.kt +++ b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/SessionContext.kt @@ -19,6 +19,9 @@ data class SessionContext( val reads: Set = emptySet(), val writes: Set = emptySet(), val activeTask: ActiveTask? = null, + // Path -> content hash captured when the file was last fully read this session; the stale-write + // gate compares it against the file's current hash. Only whole-file reads contribute. + val readHashes: Map = emptyMap(), val pendingReads: Map> = emptyMap(), ) @@ -60,9 +63,16 @@ class SessionContextProjection(private val sessionId: SessionId) : Projection): Boolean = + ToolCapability.FILE_WRITE in capabilities + + override fun assess(input: ToolCallAssessmentInput): ToolCallAssessment { + val readHashes = input.session.readHashes + if (readHashes.isEmpty()) return ToolCallAssessment() + + val root = input.workspace.workspaceRoot + val hashByReal = readHashes.entries.associate { realOf(input, root, it.key) to it.value } + val writtenReal = input.session.writes.map { realOf(input, root, it) }.toSet() + + val issues = mutableListOf() + val observations = mutableListOf() + var disposition = RiskAction.PROCEED + + for (raw in candidatePathStrings(input.paramRoles, input.request.parameters)) { + val resolved = resolveInput(root, raw) + val real = input.probe.resolveReal(resolved) + val recorded = hashByReal[real] + if (recorded == null || real in writtenReal) continue // not read in full, or self-edited + val current = input.probe.contentHash(resolved) + val stale = current != null && current != recorded + observations += ToolCallObservation( + ruleCode = RULE_CODE, + facts = mapOf("path" to raw, "stale" to stale.toString()), + ) + if (stale) { + issues += ValidationIssue( + code = RULE_CODE, + message = "Tool '${input.request.toolName}' targets '$raw', which changed on disk " + + "since you read it — re-read it before writing so you don't clobber the change.", + severity = ValidationSeverity.ERROR, + ) + disposition = maxAction(disposition, RiskAction.BLOCK) + } + } + + return ToolCallAssessment(issues = issues, observations = observations, disposition = disposition) + } + + private fun resolveInput(root: Path, raw: String): Path { + val candidate = Path.of(raw) + return if (candidate.isAbsolute) candidate else root.resolve(candidate) + } + + private fun realOf(input: ToolCallAssessmentInput, root: Path, raw: String): Path = + input.probe.resolveReal(resolveInput(root, raw)) + + private companion object { + const val RULE_CODE = "STALE_WRITE" + } +} diff --git a/core/toolintent/src/test/kotlin/com/correx/core/toolintent/SessionContextProjectionTest.kt b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/SessionContextProjectionTest.kt index 0f1053a7..7857422f 100644 --- a/core/toolintent/src/test/kotlin/com/correx/core/toolintent/SessionContextProjectionTest.kt +++ b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/SessionContextProjectionTest.kt @@ -45,7 +45,11 @@ class SessionContextProjectionTest { ), ) - private fun completed(invId: String, affected: List = emptyList()) = stored( + private fun completed( + invId: String, + affected: List = emptyList(), + structured: Map = emptyMap(), + ) = stored( ToolExecutionCompletedEvent( invocationId = ToolInvocationId(invId), sessionId = session, @@ -55,6 +59,7 @@ class SessionContextProjectionTest { toolName = "tool", exitCode = 0, outputSummary = "ok", + structuredOutput = structured, affectedEntities = affected, durationMs = 1, tier = Tier.T1, @@ -79,6 +84,14 @@ class SessionContextProjectionTest { assertTrue(ctx.reads.isEmpty()) } + @Test + fun `a full read records its content hash for the read path`() { + val ctx = fold( + listOf(readRequested("r1", "src/A.kt"), completed("r1", structured = mapOf("contentHash" to "H1"))), + ) + assertEquals(mapOf("src/A.kt" to "H1"), ctx.readHashes) + } + @Test fun `a SessionWorkingTask event sets the active task and the latest wins`() { val ctx = fold( diff --git a/core/toolintent/src/test/kotlin/com/correx/core/toolintent/StaleWriteRuleTest.kt b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/StaleWriteRuleTest.kt new file mode 100644 index 00000000..2e091c4c --- /dev/null +++ b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/StaleWriteRuleTest.kt @@ -0,0 +1,60 @@ +package com.correx.core.toolintent + +import com.correx.core.events.events.ToolRequest +import com.correx.core.events.risk.RiskAction +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.events.types.ToolInvocationId +import com.correx.core.toolintent.rules.StaleWriteRule +import com.correx.core.tools.contract.ToolCapability +import java.nio.file.Path +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class StaleWriteRuleTest { + + private val workspace = Path.of("/work/project") + private val rule = StaleWriteRule() + private val target = "/work/project/src/A.kt" + + private class FakeProbe(private val hashes: Map) : WorldProbe { + override fun exists(path: Path) = true + override fun resolveReal(path: Path): Path = path.toAbsolutePath().normalize() + override fun contentHash(path: Path): String? = hashes[path.toAbsolutePath().normalize()] + } + + private fun abs(p: String): Path = Path.of(p).toAbsolutePath().normalize() + + private fun input(onDisk: String, session: SessionContext) = ToolCallAssessmentInput( + request = ToolRequest(ToolInvocationId("i"), SessionId("s"), StageId("st"), "file_edit", mapOf("path" to target)), + capabilities = setOf(ToolCapability.FILE_WRITE), + workspace = WorkspacePolicy(workspace, emptyList()), + probe = FakeProbe(mapOf(abs(target) to onDisk)), + session = session, + ) + + @Test + fun `unchanged since read proceeds`() { + val r = rule.assess(input(onDisk = "H1", SessionContext(readHashes = mapOf(target to "H1")))) + assertEquals(RiskAction.PROCEED, r.disposition) + } + + @Test + fun `changed on disk since read is blocked`() { + val r = rule.assess(input(onDisk = "H2", SessionContext(readHashes = mapOf(target to "H1")))) + assertEquals(RiskAction.BLOCK, r.disposition) + assertEquals("STALE_WRITE", r.issues.single().code) + } + + @Test + fun `a file the session wrote itself is not stale`() { + val session = SessionContext(readHashes = mapOf(target to "H1"), writes = setOf(target)) + assertEquals(RiskAction.PROCEED, rule.assess(input(onDisk = "H2", session)).disposition) + } + + @Test + fun `a file never fully read is not this gate's concern`() { + assertEquals(RiskAction.PROCEED, rule.assess(input(onDisk = "H2", SessionContext())).disposition) + } +} diff --git a/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileReadTool.kt b/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileReadTool.kt index 6aca46fa..526d8a7b 100644 --- a/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileReadTool.kt +++ b/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileReadTool.kt @@ -142,9 +142,13 @@ class FileReadTool( val start = ((startLine ?: 1) - 1).coerceAtLeast(0) val end = (endLine ?: lines.size).coerceAtMost(lines.size) val content = lines.subList(start, end).joinToString("\n") + // Record a content hash only for a whole-file read, so the stale-write gate baselines against + // what the agent actually saw; a partial read establishes no baseline. + val metadata = if (startLine == null && endLine == null) mapOf("contentHash" to sha256(path)) else emptyMap() ToolResult.Success( invocationId = request.invocationId, output = content, + metadata = metadata, ) }.getOrElse { ToolResult.Failure( @@ -154,6 +158,10 @@ class FileReadTool( ) } + private fun sha256(path: Path): String = + java.security.MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(path)) + .joinToString("") { "%02x".format(it) } + private fun listDir(path: Path, request: ToolRequest): ToolResult = runCatching { val entries = path.listDirectoryEntries().sortedBy { it.name } val output = entries.joinToString("\n") { entry -> From 67384691e0fed8d9f51299ce35b0c374a6a441d4 Mon Sep 17 00:00:00 2001 From: kami Date: Thu, 25 Jun 2026 06:46:41 +0000 Subject: [PATCH 102/107] feat(tasks): force overrides require a recorded reason Bypassing a gate with force=true now requires a force_reason, recorded as a "[force] ..." note on the task so the override is auditable (visible in history and the context bundle) rather than a silent escape hatch. Applies to the agent tools: task_create (dedup override) and task_update (claim over unmet blockers, complete with no in-scope writes). force without a reason is rejected. The REST POST /tasks create override (operator/CLI path) still takes a bare force and is left for a separate call. Co-Authored-By: Claude Opus 4.8 --- .../tools/task/TaskCreateTool.kt | 35 ++++++++++++------ .../tools/task/TaskUpdateTool.kt | 29 ++++++++++++--- .../tools/task/TaskToolsTest.kt | 36 ++++++++++++++++--- 3 files changed, 82 insertions(+), 18 deletions(-) diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskCreateTool.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskCreateTool.kt index 5792dd0b..e5691dcb 100644 --- a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskCreateTool.kt +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskCreateTool.kt @@ -3,6 +3,7 @@ package com.correx.infrastructure.tools.task import com.correx.core.approvals.Tier import com.correx.core.events.events.ToolRequest import com.correx.core.events.types.ProjectId +import com.correx.core.events.types.TaskNoteAuthor import com.correx.core.tasks.TaskService import com.correx.core.tools.contract.Tool import com.correx.core.tools.contract.ToolCapability @@ -55,6 +56,10 @@ class TaskCreateTool(private val service: TaskService) : Tool, ToolExecutor { put("type", "boolean") put("description", "Create even if an active task with the same title exists. Default false.") } + putJsonObject("force_reason") { + put("type", "string") + put("description", "Required when force=true: why a duplicate is acceptable. Recorded on the task.") + } } put( "required", @@ -88,6 +93,12 @@ class TaskCreateTool(private val service: TaskService) : Tool, ToolExecutor { // Provenance: tie the new task to the session that spawned it so its context bundle // can show the originating run. service.linkOriginSession(task.taskId, request) + // A forced creation bypassed the duplicate guard — record why, for the audit trail. + if (request.boolParam("force")) { + request.stringParam("force_reason")?.let { + service.addNote(task.taskId, TaskNoteAuthor.AGENT, "[force] created despite the duplicate guard: $it") + } + } return ToolResult.Success( invocationId = request.invocationId, output = "Created ${task.taskId.value}: ${task.state.title}", @@ -95,22 +106,26 @@ class TaskCreateTool(private val service: TaskService) : Tool, ToolExecutor { ) } - /** Required-field validation, then a duplicate-title guard (unless force) — null when OK to create. */ + /** + * Required-field validation; then either a force-reason requirement (force=true must carry a + * force_reason, recorded after creation) or the duplicate-title guard. Null when OK to create. + */ private fun precheck(request: ToolRequest): ToolResult.Failure? { val invalid = validateRequest(request) as? ValidationResult.Invalid if (invalid != null) return ToolResult.Failure(request.invocationId, invalid.reason, recoverable = false) - val duplicates = if (request.boolParam("force")) { - emptyList() - } else { - service.findDuplicates(ProjectId(request.stringParam("project")!!), request.stringParam("title")!!) + if (request.boolParam("force")) { + if (request.stringParam("force_reason") == null) { + return fail(request, "force=true requires 'force_reason' explaining why a duplicate is acceptable.") + } + return null } + val duplicates = service.findDuplicates(ProjectId(request.stringParam("project")!!), request.stringParam("title")!!) return duplicates.takeIf { it.isNotEmpty() }?.let { val listed = it.joinToString(", ") { d -> "${d.taskId.value} '${d.state.title.orEmpty()}' [${d.state.status.name}]" } - ToolResult.Failure( - request.invocationId, - "Possible duplicate(s): $listed. Reuse one (task_context/task_update), or pass force=true.", - recoverable = false, - ) + fail(request, "Possible duplicate(s): $listed. Reuse one (task_context/task_update), or force=true with force_reason.") } } + + private fun fail(request: ToolRequest, reason: String): ToolResult.Failure = + ToolResult.Failure(request.invocationId, reason, recoverable = false) } diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt index 571cb1d2..3710fef9 100644 --- a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt @@ -81,7 +81,11 @@ class TaskUpdateTool( putJsonObject("note") { put("type", "string"); put("description", "Append an agent note.") } putJsonObject("force") { put("type", "boolean") - put("description", "Claim even if the task has unmet blockers. Default false.") + put("description", "Override a gate (claim over unmet blockers; complete with no in-scope writes). Default false.") + } + putJsonObject("force_reason") { + put("type", "string") + put("description", "Required when force=true: why the override is justified. Recorded as a note on the task.") } } put("required", buildJsonArray { add(JsonPrimitive("id")) }) @@ -101,6 +105,7 @@ class TaskUpdateTool( if (service.getTask(taskId) == null) return fail(request, "No such task: $id") val action = request.stringParam("action") + forceReasonGate(request)?.let { return it } claimBlock(request, taskId, action)?.let { return it } completeBlock(request, taskId, action)?.let { return it } @@ -121,6 +126,7 @@ class TaskUpdateTool( request.stringParam("note")?.let { service.addNote(taskId, TaskNoteAuthor.AGENT, it) } + recordForceNote(request, taskId, action) recordWorkingTask(request, taskId, action) val status = service.getTask(taskId)?.state?.status?.name ?: "UNKNOWN" @@ -132,13 +138,28 @@ class TaskUpdateTool( ) } - /** Refuse to claim a task with unmet blockers (escapable with force) — fail before any mutation. */ + /** A force override must carry a recorded justification — reject force=true with no force_reason. */ + private fun forceReasonGate(request: ToolRequest): ToolResult.Failure? = + if (request.boolParam("force") && request.stringParam("force_reason") == null) { + fail(request, "force=true requires 'force_reason' explaining the override.") + } else { + null + } + + /** A forced claim/complete bypassed a gate — record why on the task, for the audit trail. */ + private suspend fun recordForceNote(request: ToolRequest, taskId: TaskId, action: String?) { + if (!request.boolParam("force")) return + val reason = request.stringParam("force_reason") ?: return + service.addNote(taskId, TaskNoteAuthor.AGENT, "[force] ${action ?: "update"}: $reason") + } + + /** Refuse to claim a task with unmet blockers (escapable with force+reason) — fail before any mutation. */ private fun claimBlock(request: ToolRequest, taskId: TaskId, action: String?): ToolResult.Failure? { if (action != "claim" || request.boolParam("force")) return null val unmet = service.blockers(taskId) if (unmet.isEmpty()) return null val listed = unmet.joinToString(", ") { "${it.taskId.value} [${it.state.status.name}]" } - return fail(request, "Cannot claim ${taskId.value} — unmet blockers: $listed. Complete them, or pass force=true.") + return fail(request, "Cannot claim ${taskId.value} — unmet blockers: $listed. Complete them, or force=true with force_reason.") } /** @@ -155,7 +176,7 @@ class TaskUpdateTool( return fail( request, "Cannot complete ${taskId.value} — no changes under its affected_paths ($scope) this " + - "session. Make the change, or pass force=true.", + "session. Make the change, or force=true with force_reason.", ) } diff --git a/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt b/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt index e6b395eb..dd614d72 100644 --- a/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt +++ b/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt @@ -174,17 +174,33 @@ class TaskToolsTest { } @Test - fun `task_create blocks a duplicate title and force overrides`() = runBlocking { + fun `task_create blocks a duplicate title and force needs a recorded reason`() = runBlocking { create.execute(request("task_create", mapOf("project" to "auth", "title" to "JWT refresh", "goal" to "g"))) val dup = create.execute(request("task_create", mapOf("project" to "auth", "title" to "jwt refresh", "goal" to "g"))) assertTrue(dup is ToolResult.Failure) assertTrue((dup as ToolResult.Failure).reason.contains("duplicate", ignoreCase = true)) - val forced = create.execute( + // force without a reason is rejected. + val noReason = create.execute( request("task_create", mapOf("project" to "auth", "title" to "jwt refresh", "goal" to "g", "force" to true)), ) + assertTrue(noReason is ToolResult.Failure) + assertTrue((noReason as ToolResult.Failure).reason.contains("force_reason")) + + // force with a reason creates, and records the reason as a note. + val forced = create.execute( + request( + "task_create", + mapOf("project" to "auth", "title" to "jwt refresh", "goal" to "g", + "force" to true, "force_reason" to "separate refresh path for mobile"), + ), + ) assertTrue(forced is ToolResult.Success) + val id = (forced as ToolResult.Success).metadata.getValue("taskId") + val note = service.getTask(TaskId(id))!!.state.notes.single() + assertTrue(note.body.contains("[force]")) + assertTrue(note.body.contains("separate refresh path for mobile")) } @Test @@ -204,10 +220,22 @@ class TaskToolsTest { assertTrue((blocked as ToolResult.Failure).reason.contains("blocker", ignoreCase = true)) assertEquals(TaskStatus.TODO, service.getTask(TaskId("auth-2"))!!.state.status) // not mutated - // force=true claims anyway, with a warning surfaced. - val forced = update.execute(request("task_update", mapOf("id" to "auth-2", "action" to "claim", "force" to true))) + // force without a reason is rejected. + val noReason = update.execute(request("task_update", mapOf("id" to "auth-2", "action" to "claim", "force" to true))) + assertTrue(noReason is ToolResult.Failure) + assertTrue((noReason as ToolResult.Failure).reason.contains("force_reason")) + assertEquals(TaskStatus.TODO, service.getTask(TaskId("auth-2"))!!.state.status) + + // force with a reason claims, records the reason as a note, and warns. + val forced = update.execute( + request( + "task_update", + mapOf("id" to "auth-2", "action" to "claim", "force" to true, "force_reason" to "prep work, auth-1 lands soon"), + ), + ) assertTrue((forced as ToolResult.Success).output.contains("WARNING")) assertEquals(TaskStatus.IN_PROGRESS, service.getTask(TaskId("auth-2"))!!.state.status) + assertTrue(service.getTask(TaskId("auth-2"))!!.state.notes.any { it.body.contains("[force]") && it.body.contains("prep work") }) } @Test From 21e01da3ac4fe0f9643bc39e0b4e41da5bda8bb8 Mon Sep 17 00:00:00 2001 From: kami Date: Thu, 25 Jun 2026 10:39:03 +0000 Subject: [PATCH 103/107] feat(tasks): task_decompose splits a goal into a dependency-linked graph in one approval The freestyle analyst can now break a large goal with dependency seams or independent review/handoff points into a parent epic + DEPENDS_ON-linked children in a single T2 approval, instead of N separate task_create calls. Parent DEPENDS_ON every child (completes last); each child IMPLEMENTS parent. Resolves depends_on by ref or index; rejects cycles, unresolved refs, missing title/goal, and empty batches; same batch dedup + force_reason convention as task_create. A session works one active task, so multi-task work is multi-session by construction: the analyst names the single ready task this run works, the architect threads only that one, and siblings are claimed by later runs via task_ready (claim-driven; no scheduler, /tasks/next stays rejected). Doctrine: analyst_freestyle.md picks one-task-vs-decompose and names the ready task; architect_freestyle.md threads only that one; plus the L0 policy line. freestyle_planning.toml analyst gains task_decompose (pinned by FreestylePlanningWorkflowTest). Co-Authored-By: Claude Opus 4.8 --- .correx/project.toml | 2 +- examples/workflows/freestyle_planning.toml | 9 +- .../workflows/prompts/analyst_freestyle.md | 18 +- .../workflows/prompts/architect_freestyle.md | 10 +- .../tools/task/TaskDecomposeTool.kt | 305 ++++++++++++++++++ .../infrastructure/tools/task/TaskTools.kt | 1 + .../tools/task/TaskToolsTest.kt | 111 +++++++ .../workflow/FreestylePlanningWorkflowTest.kt | 5 +- 8 files changed, 446 insertions(+), 15 deletions(-) create mode 100644 infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskDecomposeTool.kt diff --git a/.correx/project.toml b/.correx/project.toml index 3bf52623..cec04e25 100644 --- a/.correx/project.toml +++ b/.correx/project.toml @@ -5,7 +5,7 @@ conventions = [ "Dependency direction: apps -> core -> infrastructure; no cross-core imports", "No bare try-catch; use runCatching and sealed domain error types", "Every new EventPayload must be registered in the eventModule polymorphic block (Serialization.kt)", - "Track multi-session or handoff work as native tasks: search before creating to avoid duplicates (task_search), task_context before starting, claim before working, submit_for_review when ready, complete after review. Don't open a task for a single self-contained edit you finish now.", + "Track multi-session or handoff work as native tasks: search before creating to avoid duplicates (task_search), task_context before starting, claim before working, submit_for_review when ready, complete after review. Don't open a task for a single self-contained edit you finish now. When a goal has dependency seams or independent review points, task_decompose it into a parent + DEPENDS_ON-linked children (one approval) instead of one big task; a session works one task at a time, so siblings are claimed by later runs as they unblock.", ] [commands] diff --git a/examples/workflows/freestyle_planning.toml b/examples/workflows/freestyle_planning.toml index 3eec738e..a58457ce 100644 --- a/examples/workflows/freestyle_planning.toml +++ b/examples/workflows/freestyle_planning.toml @@ -2,14 +2,15 @@ id = "freestyle_planning" start = "analyst" # analyst writes no files, but it owns task framing: task_search/task_context (read-only) find -# existing work, and task_create (T2, approval-gated — a task is an event-log entry, not a file -# write) opens a task for this work and names its id in the analysis, so the architect can thread -# it into the plan's implementation stages. +# existing work; task_create (T2, approval-gated — a task is an event-log entry, not a file write) +# opens a single task; task_decompose (T2, one approval for the whole graph) splits a goal with +# dependency seams into parent + DEPENDS_ON-linked children. Either way the analysis names the task +# id the run will work, so the architect threads it into the plan's implementation stages. [[stages]] id = "analyst" prompt = "prompts/analyst_freestyle.md" produces = [{ name = "analysis", kind = "analysis" }] -allowed_tools = ["file_read", "ShellTool", "task_search", "task_context", "task_create"] +allowed_tools = ["file_read", "ShellTool", "task_search", "task_context", "task_create", "task_decompose"] token_budget = 16384 max_retries = 2 diff --git a/examples/workflows/prompts/analyst_freestyle.md b/examples/workflows/prompts/analyst_freestyle.md index f5fb363a..7772014c 100644 --- a/examples/workflows/prompts/analyst_freestyle.md +++ b/examples/workflows/prompts/analyst_freestyle.md @@ -4,10 +4,20 @@ given a directory path), `ls`, `grep`, `cat`, `find`. Before deriving requirements, check for existing work: `task_search` for related, duplicate, or blocking tasks and `task_context` to load any the goal names. Fold what you find into the -analysis rather than re-deriving it; flag a duplicate instead of restating it. If a task already -covers this work, name its id (e.g. `auth-142`) in the analysis; if none does and the work -warrants tracking (per the task policy), `task_create` one and name its id — either way later -stages thread it through the plan. +analysis rather than re-deriving it; flag a duplicate instead of restating it. + +Then frame the work as a task (per the task policy): +- If a task already covers this work, name its id (e.g. `auth-142`) in the analysis. +- If the goal is a single coherent unit one run can carry to review, `task_create` one and name its + id. +- If the goal has **dependency seams** (a thing that must land before another) or **independent + review/handoff points** (a piece worth shipping or reviewing on its own), `task_decompose` it into + a parent epic + `DEPENDS_ON`-linked children — one approval for the whole graph. A session works + one task at a time, so the children are claimed by *later* runs as they unblock; don't over-split. +- After decomposing, **name in the analysis the single task this run will work** — the one already + ready (no unmet dependency, e.g. the scaffold). Leave the blocked siblings for future runs. + +Either way later stages thread the named task through the plan; the rest wait to be claimed. Emit the `analysis` artifact (JSON, schema provided): - `summary`: the goal in your own words. diff --git a/examples/workflows/prompts/architect_freestyle.md b/examples/workflows/prompts/architect_freestyle.md index f97b2844..51790137 100644 --- a/examples/workflows/prompts/architect_freestyle.md +++ b/examples/workflows/prompts/architect_freestyle.md @@ -63,15 +63,17 @@ Emit a JSON object that validates against the `execution_plan` schema: (`["file_read", "file_write", "file_edit", "ShellTool"]`). Do not invent names beyond this set. - **Task tracking — only if the `analysis` references a task** (an id like `auth-142` that - the analyst found or opened with `task_create`; if none is referenced there is no task to - track). When one is referenced, thread it through the plan so the work stays tracked: + the analyst found, opened with `task_create`, or named as the ready task of a + `task_decompose` graph; if none is referenced there is no task to track). Thread **only that + one task** — this run works a single task; any sibling tasks the analyst decomposed are for + later runs to claim, so do not plan or reference them here. When one is referenced: - Give the stage that does the work `task_context` and `task_update`, and have its `prompt` `task_update action=claim` the task before starting and `action=submit_for_review` when its output is ready. - Give the final or review stage `task_context` and `task_update`, and have its `prompt` `task_update action=complete` the task once the work is accepted. - - If the `analysis` references no task, omit the task tools entirely. Do not create a - new task here — creation is out of scope for the plan. + - If the `analysis` references no task, omit the task tools entirely. Do not create or + decompose tasks here — task creation is out of scope for the plan. - Keep stages small and single-responsibility. Prefer more stages over large monolithic prompts. diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskDecomposeTool.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskDecomposeTool.kt new file mode 100644 index 00000000..0dae9869 --- /dev/null +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskDecomposeTool.kt @@ -0,0 +1,305 @@ +package com.correx.infrastructure.tools.task + +import com.correx.core.approvals.Tier +import com.correx.core.events.events.ToolRequest +import com.correx.core.events.types.ProjectId +import com.correx.core.events.types.TaskLinkType +import com.correx.core.events.types.TaskNoteAuthor +import com.correx.core.events.types.TaskTargetKind +import com.correx.core.tasks.Task +import com.correx.core.tasks.TaskService +import com.correx.core.tools.contract.Tool +import com.correx.core.tools.contract.ToolCapability +import com.correx.core.tools.contract.ToolExecutor +import com.correx.core.tools.contract.ToolResult +import com.correx.core.tools.contract.ValidationResult +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.add +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonObject + +/** + * Agent-facing tool: break a large goal into a small task graph in ONE approval, instead of N + * separate [TaskCreateTool] calls. Use when the goal has dependency seams or independent + * review/handoff points (a thing that must land before another, or a piece worth reviewing on its + * own); for a single coherent unit you finish in one run, use task_create. Because a session works + * one task at a time, each node becomes a future claim — this tool decides the *shape*, claiming is + * still lazy (task_ready → claim), never scheduled. + * + * Creates the tasks and their `DEPENDS_ON` edges atomically (one dedup check, one approval) and + * returns the new ids plus which are ready to work now. An optional `parent` epic `DEPENDS_ON` every + * child (so it completes last) and each child `IMPLEMENTS` it (provenance). + * + * Nested args arrive flattened (the orchestrator stringifies non-primitive tool arguments), so the + * `tasks`/`parent` JSON is parsed here directly rather than via the list-param accessor. + */ +class TaskDecomposeTool(private val service: TaskService) : Tool, ToolExecutor { + + override val name: String = "task_decompose" + override val description: String = + "Break a large goal into a small task graph (parent epic + dependency-linked children) in " + + "one shot. Use when the goal has dependency seams or independent review/handoff points; " + + "for a single coherent unit you finish now, use task_create. Creates the tasks and their " + + "DEPENDS_ON links in a single approval; returns the new ids and which are ready to work now." + override val parametersSchema: JsonObject = buildJsonObject { + put("type", "object") + putJsonObject("properties") { + putJsonObject("project") { + put("type", "string") + put("description", "Project key shared by every task, e.g. 'webui'. Ids become '-'.") + } + putJsonObject("parent") { + put("type", "object") + put( + "description", + "Optional umbrella/epic. It DEPENDS_ON every child (completes last); each child IMPLEMENTS it.", + ) + putJsonObject("properties") { unitProperties() } + } + putJsonObject("tasks") { + put("type", "array") + put("description", "The units of work, in order. Declare cross-task ordering with depends_on.") + putJsonObject("items") { + put("type", "object") + putJsonObject("properties") { + putJsonObject("ref") { + put("type", "string") + put("description", "Local handle other tasks cite in depends_on (e.g. 'scaffold').") + } + unitProperties() + putJsonObject("depends_on") { + put("type", "array") + putJsonObject("items") { put("type", "string") } + put("description", "refs (or 0-based indices) of tasks in THIS batch that must finish first.") + } + } + put("required", buildJsonArray { add(JsonPrimitive("title")); add(JsonPrimitive("goal")) }) + } + } + putJsonObject("force") { + put("type", "boolean") + put("description", "Create even if a title duplicates an active task. Default false.") + } + putJsonObject("force_reason") { + put("type", "string") + put("description", "Required when force=true: why duplicates are acceptable. Recorded on each task.") + } + } + put("required", buildJsonArray { add(JsonPrimitive("project")); add(JsonPrimitive("tasks")) }) + } + override val tier: Tier = Tier.T2 + override val requiredCapabilities: Set = emptySet() + + override fun validateRequest(request: ToolRequest): ValidationResult { + request.stringParam("project") ?: return ValidationResult.Invalid("Missing 'project' (string).") + if (elementOf(request, "tasks") !is JsonArray) { + return ValidationResult.Invalid("Missing 'tasks' (JSON array).") + } + return ValidationResult.Valid + } + + override suspend fun execute(request: ToolRequest): ToolResult { + val project = request.stringParam("project") ?: return fail(request, "Missing 'project' (string).") + val specs = parseSpecs(elementOf(request, "tasks") as? JsonArray) + ?: return fail(request, "'tasks' must be a JSON array of {title, goal, ...}.") + if (specs.isEmpty()) return fail(request, "'tasks' must contain at least one task.") + specs.withIndex().firstOrNull { it.value.title.isBlank() || it.value.goal.isBlank() }?.let { + return fail(request, "task #${it.index} is missing 'title' or 'goal'.") + } + val parent = parseSpec(elementOf(request, "parent") as? JsonObject) + if (parent != null && (parent.title.isBlank() || parent.goal.isBlank())) { + return fail(request, "'parent' needs both 'title' and 'goal'.") + } + + val adjacency = when (val e = buildEdges(specs)) { + is Edges.Err -> return fail(request, e.message) + is Edges.Ok -> e.adjacency + } + dedupFailure(request, project, specs, parent)?.let { return it } + + val pid = ProjectId(project) + val parentTask = parent?.let { service.createTask(pid, it.title, it.goal, it.acceptanceCriteria, it.affectedPaths) } + val children = specs.map { service.createTask(pid, it.title, it.goal, it.acceptanceCriteria, it.affectedPaths) } + wireLinks(adjacency, children, parentTask) + recordProvenance(request, listOfNotNull(parentTask) + children) + + val createdIds = (children + listOfNotNull(parentTask)).joinToString(",") { it.taskId.value } + val readyIds = children.filter { service.blockers(it.taskId).isEmpty() }.map { it.taskId.value } + return ToolResult.Success( + invocationId = request.invocationId, + output = render(children, parentTask, readyIds), + metadata = mapOf("taskIds" to createdIds, "readyIds" to readyIds.joinToString(","), "project" to project), + ) + } + + /** Create the `DEPENDS_ON` edges between children, and (if any) the parent's epic edges. */ + private suspend fun wireLinks(adjacency: List>, children: List, parentTask: Task?) { + adjacency.forEachIndexed { i, deps -> + deps.forEach { j -> service.link(children[i].taskId, children[j].taskId.value, TaskLinkType.DEPENDS_ON, TaskTargetKind.TASK) } + } + if (parentTask != null) { + children.forEach { child -> + // Parent waits on every child; child records the epic it implements. + service.link(parentTask.taskId, child.taskId.value, TaskLinkType.DEPENDS_ON, TaskTargetKind.TASK) + service.link(child.taskId, parentTask.taskId.value, TaskLinkType.IMPLEMENTS, TaskTargetKind.TASK) + } + } + } + + private suspend fun recordProvenance(request: ToolRequest, created: List) { + created.forEach { service.linkOriginSession(it.taskId, request) } + if (!request.boolParam("force")) return + val reason = request.stringParam("force_reason") ?: return + created.forEach { + service.addNote(it.taskId, TaskNoteAuthor.AGENT, "[force] created via decompose despite the duplicate guard: $reason") + } + } + + /** Intra-batch + against-board duplicate guard, mirroring [TaskCreateTool]; force needs a reason. */ + private fun dedupFailure(request: ToolRequest, project: String, specs: List, parent: TaskSpec?): ToolResult.Failure? { + val titles = (listOfNotNull(parent?.title) + specs.map { it.title }) + val within = titles.groupBy { normalize(it) }.filter { it.value.size > 1 }.keys + if (within.isNotEmpty()) return fail(request, "Duplicate titles within this batch: ${within.joinToString(", ")}.") + if (request.boolParam("force")) { + return if (request.stringParam("force_reason") == null) { + fail(request, "force=true requires 'force_reason' explaining why a duplicate is acceptable.") + } else { + null + } + } + val pid = ProjectId(project) + val clashes = titles.flatMap { t -> service.findDuplicates(pid, t) }.distinctBy { it.taskId.value } + return clashes.takeIf { it.isNotEmpty() }?.let { + val listed = it.joinToString(", ") { d -> "${d.taskId.value} '${d.state.title.orEmpty()}' [${d.state.status.name}]" } + fail(request, "Possible duplicate(s): $listed. Reuse one (task_context/task_update), or force=true with force_reason.") + } + } + + private fun render(children: List, parentTask: Task?, readyIds: List): String { + val lines = children.map { c -> + val unmet = service.blockers(c.taskId).map { it.taskId.value } + val state = if (unmet.isEmpty()) "READY" else "blocked by ${unmet.joinToString(", ")}" + "- ${c.taskId.value} '${c.state.title.orEmpty()}' [$state]" + } + listOfNotNull( + parentTask?.let { p -> + val unmet = service.blockers(p.taskId).map { it.taskId.value } + "- ${p.taskId.value} '${p.state.title.orEmpty()}' (epic) [blocked by ${unmet.joinToString(", ")}]" + }, + ) + val readyLine = if (readyIds.isEmpty()) { + "Nothing is ready yet — check the dependency graph." + } else { + "Ready to work now: ${readyIds.joinToString(", ")}. Claim one to start; the rest unblock as their dependencies complete." + } + return "Decomposed into ${children.size} task(s)${if (parentTask != null) " under an epic" else ""}:\n" + + lines.joinToString("\n") + "\n" + readyLine + } + + // --- parsing (flattened JSON args) --- + + private data class TaskSpec( + val ref: String?, + val title: String, + val goal: String, + val acceptanceCriteria: List, + val affectedPaths: List, + val deps: List, + ) + + private fun parseSpecs(element: JsonArray?): List? = + element?.map { parseSpec(it as? JsonObject) ?: return null } + + private fun parseSpec(obj: JsonObject?): TaskSpec? { + if (obj == null) return null + return TaskSpec( + ref = str(obj, "ref"), + title = str(obj, "title").orEmpty(), + goal = str(obj, "goal").orEmpty(), + acceptanceCriteria = strList(obj, "acceptance_criteria"), + affectedPaths = strList(obj, "affected_paths"), + deps = strList(obj, "depends_on"), + ) + } + + private fun str(obj: JsonObject, key: String): String? = + (obj[key] as? JsonPrimitive)?.content?.takeIf { it.isNotBlank() && it != "null" } + + private fun strList(obj: JsonObject, key: String): List = + (obj[key] as? JsonArray)?.mapNotNull { (it as? JsonPrimitive)?.content?.takeIf(String::isNotBlank) } ?: emptyList() + + /** Top-level args are flattened to strings by the orchestrator, so re-parse the value as JSON. */ + private fun elementOf(request: ToolRequest, name: String) = + request.parameters[name]?.let { runCatching { Json.parseToJsonElement(it.toString()) }.getOrNull() } + + private fun normalize(title: String): String = title.trim().lowercase().replace(Regex("\\s+"), " ") + + // --- dependency edges + cycle check --- + + private sealed interface Edges { + data class Ok(val adjacency: List>) : Edges + data class Err(val message: String) : Edges + } + + private fun buildEdges(specs: List): Edges { + val refToIndex = HashMap() + specs.forEachIndexed { i, s -> s.ref?.let { refToIndex[it] = i } } + val adjacency = ArrayList>() + specs.forEachIndexed { i, s -> + val deps = ArrayList() + for (d in s.deps) { + val j = refToIndex[d] ?: d.toIntOrNull()?.takeIf { it in specs.indices } + ?: return Edges.Err("task #$i ('${s.title}') depends_on '$d', which is not a ref or index in this batch.") + if (j == i) return Edges.Err("task #$i ('${s.title}') depends on itself.") + deps.add(j) + } + adjacency.add(deps.distinct()) + } + cyclePath(adjacency)?.let { path -> + return Edges.Err("Dependency cycle: ${path.joinToString(" -> ") { specs[it].title }}.") + } + return Edges.Ok(adjacency) + } + + /** Returns a back-edge cycle as a list of task indices, or null when the graph is acyclic. */ + private fun cyclePath(adjacency: List>): List? { + val color = IntArray(adjacency.size) // 0=unseen, 1=on-stack, 2=done + val stack = ArrayList() + fun dfs(u: Int): List? { + color[u] = 1 + stack.add(u) + for (v in adjacency[u]) { + if (color[v] == 1) return stack.subList(stack.indexOf(v), stack.size).toList() + v + if (color[v] == 0) dfs(v)?.let { return it } + } + color[u] = 2 + stack.removeAt(stack.lastIndex) + return null + } + for (i in adjacency.indices) if (color[i] == 0) dfs(i)?.let { return it } + return null + } + + private fun fail(request: ToolRequest, reason: String): ToolResult.Failure = + ToolResult.Failure(request.invocationId, reason, recoverable = false) +} + +/** The fields a task spec and the parent epic share, declared once for the schema. */ +private fun kotlinx.serialization.json.JsonObjectBuilder.unitProperties() { + putJsonObject("title") { put("type", "string"); put("description", "Short imperative title.") } + putJsonObject("goal") { put("type", "string"); put("description", "What 'done' looks like.") } + putJsonObject("acceptance_criteria") { + put("type", "array") + putJsonObject("items") { put("type", "string") } + put("description", "Concrete, verifiable criteria.") + } + putJsonObject("affected_paths") { + put("type", "array") + putJsonObject("items") { put("type", "string") } + put("description", "Globs/paths this task is expected to touch.") + } +} diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskTools.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskTools.kt index e3103af5..150490e3 100644 --- a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskTools.kt +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskTools.kt @@ -26,6 +26,7 @@ object TaskTools { ): List = listOf( TaskCreateTool(service), + TaskDecomposeTool(service), TaskUpdateTool(service, sessionFacts, sessionWrites), TaskDeleteTool(service), TaskSearchTool(service), diff --git a/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt b/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt index dd614d72..49a38b99 100644 --- a/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt +++ b/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt @@ -27,6 +27,7 @@ class TaskToolsTest { private val service = TaskService(InMemoryEventStore()) private val create = TaskCreateTool(service) + private val decompose = TaskDecomposeTool(service) private val update = TaskUpdateTool(service) private val delete = TaskDeleteTool(service) private val search = TaskSearchTool(service) @@ -63,6 +64,116 @@ class TaskToolsTest { assertTrue(result is ToolResult.Failure) } + // task_decompose receives nested args as JSON strings (the orchestrator flattens non-primitive + // tool arguments), so the tests pass strings to exercise the real parse path — not structured Lists. + private val threeTaskGraph = """ + [ + {"ref":"scaffold","title":"Scaffold app","goal":"shell + build","affected_paths":["apps/web/**"]}, + {"ref":"auth","title":"Auth view","goal":"login","depends_on":["scaffold"]}, + {"ref":"dash","title":"Dashboard","goal":"home","depends_on":["scaffold"]} + ] + """.trimIndent() + + @Test + fun `task_decompose builds a DEPENDS_ON graph with one ready root and a blocked epic`() = runBlocking { + val result = decompose.execute( + request( + "task_decompose", + mapOf( + "project" to "webui", + "parent" to """{"title":"Frontend web UI","goal":"web ui for correx"}""", + "tasks" to threeTaskGraph, + ), + ), + ) + assertTrue(result is ToolResult.Success) + val meta = (result as ToolResult.Success).metadata + // parent created first (webui-1), then children in order (webui-2..4). + assertEquals("webui-2,webui-3,webui-4,webui-1", meta.getValue("taskIds")) + assertEquals("webui-2", meta.getValue("readyIds")) + // affected_paths survived the JSON parse (not dropped like listParam would on the flattened arg). + assertEquals(listOf("apps/web/**"), service.getTask(TaskId("webui-2"))!!.state.affectedPaths) + // scaffold is the only ready task; the dependents and the epic are blocked. + assertEquals(listOf("webui-2"), service.ready(com.correx.core.events.types.ProjectId("webui")).map { it.taskId.value }) + assertEquals(listOf("webui-2"), service.blockers(TaskId("webui-3")).map { it.taskId.value }) + assertEquals(3, service.blockers(TaskId("webui-1")).size) + // child implements the epic; epic depends on the child. + assertTrue( + service.getTask(TaskId("webui-2"))!!.state.links.any { + it.type == TaskLinkType.IMPLEMENTS && it.targetKind == TaskTargetKind.TASK && it.targetId == "webui-1" + }, + ) + } + + @Test + fun `task_decompose resolves depends_on by index`() = runBlocking { + val result = decompose.execute( + request( + "task_decompose", + mapOf( + "project" to "webui", + "tasks" to """[{"title":"Scaffold","goal":"g"},{"title":"Wire","goal":"g","depends_on":["0"]}]""", + ), + ), + ) + assertTrue(result is ToolResult.Success) + assertEquals(listOf("webui-1"), service.blockers(TaskId("webui-2")).map { it.taskId.value }) + } + + @Test + fun `task_decompose rejects a dependency cycle`() = runBlocking { + val result = decompose.execute( + request( + "task_decompose", + mapOf( + "project" to "webui", + "tasks" to """[{"ref":"a","title":"A","goal":"g","depends_on":["b"]},{"ref":"b","title":"B","goal":"g","depends_on":["a"]}]""", + ), + ), + ) + assertTrue(result is ToolResult.Failure) + assertTrue((result as ToolResult.Failure).reason.contains("cycle", ignoreCase = true)) + } + + @Test + fun `task_decompose rejects an unresolved dependency reference`() = runBlocking { + val result = decompose.execute( + request( + "task_decompose", + mapOf("project" to "webui", "tasks" to """[{"title":"A","goal":"g","depends_on":["ghost"]}]"""), + ), + ) + assertTrue(result is ToolResult.Failure) + } + + @Test + fun `task_decompose rejects a task missing title or goal`() = runBlocking { + val result = decompose.execute( + request("task_decompose", mapOf("project" to "webui", "tasks" to """[{"title":"A"}]""")), + ) + assertTrue(result is ToolResult.Failure) + } + + @Test + fun `task_decompose rejects an empty task list`() = runBlocking { + val result = decompose.execute(request("task_decompose", mapOf("project" to "webui", "tasks" to "[]"))) + assertTrue(result is ToolResult.Failure) + } + + @Test + fun `task_decompose blocks a duplicate title and force needs a recorded reason`() = runBlocking { + create.execute(request("task_create", mapOf("project" to "webui", "title" to "Scaffold app", "goal" to "g"))) + val dup = mapOf("project" to "webui", "tasks" to """[{"title":"Scaffold app","goal":"again"}]""") + + assertTrue(decompose.execute(request("task_decompose", dup)) is ToolResult.Failure) + assertTrue(decompose.execute(request("task_decompose", dup + ("force" to true))) is ToolResult.Failure) + + val forced = decompose.execute(request("task_decompose", dup + ("force" to true) + ("force_reason" to "intentional rebuild"))) + assertTrue(forced is ToolResult.Success) + val id = (forced as ToolResult.Success).metadata.getValue("taskIds").substringBefore(",") + assertTrue(service.getTask(TaskId(id))!!.state.notes.any { it.body.startsWith("[force]") }) + } + @Test fun `task_update applies status action, edits, link and note`() = runBlocking { val id = createTask() diff --git a/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/FreestylePlanningWorkflowTest.kt b/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/FreestylePlanningWorkflowTest.kt index 4dd07652..2fe406d9 100644 --- a/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/FreestylePlanningWorkflowTest.kt +++ b/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/FreestylePlanningWorkflowTest.kt @@ -70,9 +70,10 @@ class FreestylePlanningWorkflowTest { assertEquals(2, graph.transitions.size) - // analyst frames the work: search + open a task (the architect threads it into the plan). + // analyst frames the work: search + open one task or decompose into a graph (the architect + // threads the named task into the plan). assertEquals( - setOf("file_read", "ShellTool", "task_search", "task_context", "task_create"), + setOf("file_read", "ShellTool", "task_search", "task_context", "task_create", "task_decompose"), graph.stages[StageId("analyst")]!!.allowedTools, ) } From 5d61cca34ccfa07f4b086a283bf567182d9f3cc5 Mon Sep 17 00:00:00 2001 From: kami Date: Thu, 25 Jun 2026 10:39:03 +0000 Subject: [PATCH 104/107] fix(kernel): flatten tool-call array args to List so affected_paths isn't dropped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The orchestrator stringified every non-primitive tool argument, so a JSON array arrived as its toString() and listParam (as? List<*>) read it empty — silently dropping a task's affected_paths/acceptance_criteria. That left WriteScopeRule (activeTask.scope empty -> no-op) and cite-before-claim with nothing to enforce for agent-created tasks. Extract parseToolArguments: a primitive array becomes List; objects and arrays-of-objects keep their JSON text for tools that re-parse them (task_decompose). Unit-tested in ParseToolArgumentsTest. Co-Authored-By: Claude Opus 4.8 --- .../orchestration/SessionOrchestrator.kt | 29 ++++++++++---- .../orchestration/ParseToolArgumentsTest.kt | 40 +++++++++++++++++++ 2 files changed, 62 insertions(+), 7 deletions(-) create mode 100644 core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/ParseToolArgumentsTest.kt diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt index 7515962f..af7a37fe 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt @@ -136,7 +136,9 @@ import kotlinx.coroutines.withTimeout import kotlinx.datetime.Clock import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive import com.correx.core.journal.DecisionJournalRenderer import com.correx.core.journal.DefaultDecisionJournalRepository import com.correx.core.kernel.orchestration.subagent.SubagentRunner @@ -623,13 +625,7 @@ abstract class SessionOrchestrator( val fileWrittenSlots = stageConfig.produces.filter { it.kind.id == "file_written" } return toolCalls.flatMap { toolCall -> val invocationId = ToolInvocationId(UUID.randomUUID().toString()) - val parameters = runCatching { - val element = Json.parseToJsonElement(toolCall.function.arguments) - val jsonObject = element as? kotlinx.serialization.json.JsonObject ?: return@runCatching emptyMap() - jsonObject.entries.associate { (k, v) -> - k to ((v as? kotlinx.serialization.json.JsonPrimitive)?.content ?: v.toString()) as Any - } - }.getOrElse { emptyMap() } + val parameters = parseToolArguments(toolCall.function.arguments) val request = ToolRequest( invocationId = invocationId, sessionId = sessionId, @@ -2111,3 +2107,22 @@ internal sealed interface InferenceResult { data class Failed(val reason: String) : InferenceResult data object Cancelled : InferenceResult } + +/** + * Flattens an LLM tool call's JSON arguments into a [ToolRequest.parameters] map. A JSON array of + * primitives becomes a `List` so list-param accessors read it — otherwise it arrived as a + * `.toString()` string and silently read empty, dropping e.g. a task's `affected_paths` (which in + * turn left the write-scope and cite-before-claim gates with nothing to enforce). A primitive + * becomes its content string; objects and arrays-of-objects keep their JSON text for tools that + * re-parse them (e.g. task_decompose). Malformed JSON yields an empty map. + */ +internal fun parseToolArguments(arguments: String): Map = runCatching { + val obj = Json.parseToJsonElement(arguments) as? JsonObject ?: return@runCatching emptyMap() + obj.entries.associate { (k, v) -> + k to when { + v is JsonPrimitive -> v.content + v is JsonArray && v.all { it is JsonPrimitive } -> v.map { (it as JsonPrimitive).content } + else -> v.toString() + } as Any + } +}.getOrElse { emptyMap() } diff --git a/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/ParseToolArgumentsTest.kt b/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/ParseToolArgumentsTest.kt new file mode 100644 index 00000000..d855eb5e --- /dev/null +++ b/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/ParseToolArgumentsTest.kt @@ -0,0 +1,40 @@ +package com.correx.core.kernel.orchestration + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class ParseToolArgumentsTest { + + @Test + fun `a string array becomes a List so list-param accessors read it`() { + // Regression: arrays were stringified and read back empty, dropping a task's affected_paths. + val params = parseToolArguments("""{"affected_paths":["apps/web/**","build.gradle"]}""") + assertEquals(listOf("apps/web/**", "build.gradle"), params["affected_paths"]) + } + + @Test + fun `primitives become their content strings`() { + val params = parseToolArguments("""{"project":"webui","force":true,"limit":5}""") + assertEquals("webui", params["project"]) + assertEquals("true", params["force"]) + assertEquals("5", params["limit"]) + } + + @Test + fun `objects and arrays-of-objects are kept as JSON text for tools that re-parse them`() { + val params = parseToolArguments( + """{"parent":{"title":"Epic"},"tasks":[{"title":"A"},{"title":"B"}]}""", + ) + assertTrue((params["parent"] as String).contains("\"title\"")) + // re-parseable JSON, not a Kotlin List#toString + assertTrue((params["tasks"] as String).trim().startsWith("[")) + assertTrue((params["tasks"] as String).contains("\"title\":\"A\"")) + } + + @Test + fun `malformed json yields an empty map`() { + assertTrue(parseToolArguments("not json").isEmpty()) + assertTrue(parseToolArguments("[]").isEmpty()) // top-level non-object + } +} From f3b3145f36b2150f56c6e01dfde706110929898b Mon Sep 17 00:00:00 2001 From: kami Date: Thu, 25 Jun 2026 11:54:24 +0000 Subject: [PATCH 105/107] feat(tasks): surface ready/blockedBy on GET /tasks and a readable decompose preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /tasks now reports dependency-graph status per task — ready (TODO with no unmet deps) and blockedBy (ids of unfinished blockers) — resolved via TaskGraph over each task's FULL project board, so a status filter can't hide a blocker. Default-valued fields are omitted (encodeDefaults=false); consumers read absent as zero. The task_decompose approval card now shows a readable graph (epic + numbered children with their "after" dependency labels) via renderDecomposePreview wired into computeToolPreview, instead of the truncated raw JSON the generic fallback gave. Co-Authored-By: Claude Opus 4.8 --- .../correx/apps/server/routes/TaskRoutes.kt | 21 ++++++++++-- .../apps/server/routes/TaskRoutesTest.kt | 34 +++++++++++++++++++ .../orchestration/SessionOrchestrator.kt | 33 ++++++++++++++++++ .../orchestration/ParseToolArgumentsTest.kt | 30 ++++++++++++++++ 4 files changed, 116 insertions(+), 2 deletions(-) diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt b/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt index 34bda54d..b1640b03 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt @@ -11,6 +11,7 @@ import com.correx.core.events.types.TaskNoteAuthor import com.correx.core.events.types.TaskTargetKind import com.correx.core.tasks.Task import com.correx.core.tasks.TaskContextAssembler +import com.correx.core.tasks.TaskGraph import com.correx.core.tasks.TaskHistory import com.correx.core.tasks.TaskMarkdown import com.correx.core.tasks.TaskSearch @@ -52,6 +53,11 @@ data class TaskResponse( val notes: List, val createdAt: String?, val updatedAt: String?, + // Dependency-graph status for the board: ready = TODO with every dependency satisfied (workable + // now); blockedBy = the ids of unfinished tasks this one waits on (its unmet DEPENDS_ON/BLOCKS). + // Default to "not blocked / not ready" for the endpoints that don't resolve the graph. + val ready: Boolean = false, + val blockedBy: List = emptyList(), ) @Serializable @@ -132,7 +138,7 @@ private fun Route.taskCollectionRoutes(service: TaskService) { if (call.request.queryParameters["ready"]?.toBoolean() == true) { val readyProject = call.request.queryParameters["project"] val workable = service.ready(readyProject?.let { ProjectId(it) }) - return@get call.respond(workable.map { it.toResponse() }) + return@get call.respond(workable.map { it.toResponse().copy(ready = true) }) } val statusRaw = call.request.queryParameters["status"] val statusFilter = statusRaw?.let { @@ -149,7 +155,18 @@ private fun Route.taskCollectionRoutes(service: TaskService) { } else { TaskSearch.search(base, q) } - call.respond(ordered.map { it.toResponse() }) + // Resolve ready/blockedBy against each task's FULL project board (not the filtered list — a + // status filter must not hide a finished blocker). One list() per distinct project, cached. + val boards = HashMap>() + fun enrich(t: Task): TaskResponse { + val pid = t.state.projectId ?: return t.toResponse() + val board = boards.getOrPut(pid) { service.list(pid) } + return t.toResponse().copy( + ready = TaskGraph.isReady(t, board), + blockedBy = TaskGraph.unmetBlockers(t, board).map { it.taskId.value }, + ) + } + call.respond(ordered.map(::enrich)) } post { val body = call.receive() diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/routes/TaskRoutesTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/routes/TaskRoutesTest.kt index 9ec3c1c0..7f97789c 100644 --- a/apps/server/src/test/kotlin/com/correx/apps/server/routes/TaskRoutesTest.kt +++ b/apps/server/src/test/kotlin/com/correx/apps/server/routes/TaskRoutesTest.kt @@ -116,6 +116,40 @@ class TaskRoutesTest { } } + @Test + fun `GET tasks reports dependency-graph ready and blockedBy`(@TempDir tempDir: Path) { + testApplication { + application { configureServer(buildModule(tempDir)) } + val client = createClient {} + client.createTask() // demo-1 (TODO, no deps) + client.post("/tasks") { + contentType(ContentType.Application.Json) + setBody("""{"project":"demo","title":"Rotate keys","goal":"rotate signing keys"}""") + } // demo-2 + // demo-2 DEPENDS_ON demo-1 (TASK kind inferred from the id). + client.post("/tasks/demo-2/links") { + contentType(ContentType.Application.Json) + setBody("""{"targetId":"demo-1","type":"DEPENDS_ON"}""") + } + + val rows = testJson.parseToJsonElement(client.get("/tasks?project=demo").bodyAsText()) as JsonArray + val byId = rows.associate { (it as JsonObject)["id"]!!.jsonPrimitive.content to it } + val one = byId.getValue("demo-1") as JsonObject + val two = byId.getValue("demo-2") as JsonObject + // default-valued fields are omitted from JSON (encodeDefaults=false), which the TUI reads + // back as zero values — so tolerate absence here too. + fun readyOf(o: JsonObject) = o["ready"]?.jsonPrimitive?.content == "true" + fun blockedByOf(o: JsonObject): List = + (o["blockedBy"] as? JsonArray)?.map { it.jsonPrimitive.content } ?: emptyList() + // demo-1: TODO with nothing in its way → ready, no blockers. + assertTrue(readyOf(one)) + assertTrue(blockedByOf(one).isEmpty()) + // demo-2: waits on the unfinished demo-1 → not ready, blockedBy = [demo-1]. + assertTrue(!readyOf(two)) + assertEquals(listOf("demo-1"), blockedByOf(two)) + } + } + @Test fun `lifecycle transitions move a task to DONE`(@TempDir tempDir: Path) { testApplication { diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt index af7a37fe..0d576763 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt @@ -2042,6 +2042,7 @@ abstract class SessionOrchestrator( */ private suspend fun computeToolPreview(toolName: String, parameters: Map): String? { if (toolName == "shell") return shellCommandPreview(parameters) + if (toolName == "task_decompose") return renderDecomposePreview(parameters) if (toolName != "file_write") return null val path = parameters["path"] as? String ?: return null val operation = parameters["operation"] as? String @@ -2126,3 +2127,35 @@ internal fun parseToolArguments(arguments: String): Map = runCatchi } as Any } }.getOrElse { emptyMap() } + +/** + * Human-readable approval-card preview of a `task_decompose` call: the proposed epic and the numbered + * child tasks with their "after" (dependency) labels — instead of the truncated raw JSON the generic + * fallback would show. The nested `tasks`/`parent` args arrive as JSON text (flattened), so they are + * re-parsed here. Null on any parse miss, so the caller falls back to the raw arguments. + */ +internal fun renderDecomposePreview(parameters: Map): String? { + val project = parameters["project"] as? String ?: return null + val tasks = (parameters["tasks"] as? String) + ?.let { runCatching { Json.parseToJsonElement(it) }.getOrNull() } as? JsonArray ?: return null + if (tasks.isEmpty()) return null + val titleAt = { i: Int -> ((tasks.getOrNull(i) as? JsonObject)?.get("title") as? JsonPrimitive)?.content } + val refToIndex = tasks.mapIndexedNotNull { i, e -> + ((e as? JsonObject)?.get("ref") as? JsonPrimitive)?.content?.let { it to i } + }.toMap() + val parentTitle = (parameters["parent"] as? String) + ?.let { runCatching { Json.parseToJsonElement(it) }.getOrNull() as? JsonObject } + ?.let { (it["title"] as? JsonPrimitive)?.content } + + return buildString { + append("Decompose '").append(project).append("' into:") + parentTitle?.let { append("\n epic: ").append(it) } + tasks.forEachIndexed { i, e -> + val o = e as? JsonObject + append("\n ").append(i + 1).append(". ").append((o?.get("title") as? JsonPrimitive)?.content ?: "(untitled)") + val afters = ((o?.get("depends_on") as? JsonArray)?.mapNotNull { (it as? JsonPrimitive)?.content } ?: emptyList()) + .map { d -> (refToIndex[d] ?: d.toIntOrNull())?.let { titleAt(it) } ?: d } + if (afters.isNotEmpty()) append(" (after: ").append(afters.joinToString(", ")).append(")") + } + } +} diff --git a/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/ParseToolArgumentsTest.kt b/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/ParseToolArgumentsTest.kt index d855eb5e..612a96b4 100644 --- a/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/ParseToolArgumentsTest.kt +++ b/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/ParseToolArgumentsTest.kt @@ -1,6 +1,7 @@ package com.correx.core.kernel.orchestration import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test @@ -37,4 +38,33 @@ class ParseToolArgumentsTest { assertTrue(parseToolArguments("not json").isEmpty()) assertTrue(parseToolArguments("[]").isEmpty()) // top-level non-object } + + @Test + fun `decompose preview renders the epic and children with dependency labels`() { + // Args arrive flattened: nested tasks/parent are JSON text, as in a real call. + val preview = renderDecomposePreview( + mapOf( + "project" to "webui", + "parent" to """{"title":"Frontend web UI"}""", + "tasks" to """ + [ + {"ref":"scaffold","title":"Scaffold app"}, + {"title":"Auth view","depends_on":["scaffold"]}, + {"title":"Dashboard","depends_on":["0"]} + ] + """.trimIndent(), + ), + )!! + assertTrue(preview.contains("epic: Frontend web UI")) + assertTrue(preview.contains("1. Scaffold app")) + // dependency labels resolve a ref and a numeric index back to the dependency's title. + assertTrue(preview.contains("2. Auth view (after: Scaffold app)")) + assertTrue(preview.contains("3. Dashboard (after: Scaffold app)")) + } + + @Test + fun `decompose preview is null when tasks are unparseable so the caller falls back`() { + assertNull(renderDecomposePreview(mapOf("project" to "webui", "tasks" to "oops"))) + assertNull(renderDecomposePreview(mapOf("tasks" to "[]"))) + } } From 6f2ee1c65447f7b970fccabb9582e691d64f2431 Mon Sep 17 00:00:00 2001 From: kami Date: Thu, 25 Jun 2026 11:54:24 +0000 Subject: [PATCH 106/107] feat(tui): show dependency readiness on the task board MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each task row gets a 2-col glyph — ● ready (workable now) / ○ waiting (blocked) / blank — and the detail pane shows "ready to work" or "blocked — waiting on ", fed by the new GET /tasks ready/blockedBy fields. Makes a decomposed graph legible at a glance; a dependency-blocked task is status TODO (not the red BLOCKED lifecycle state), so the glyph is what distinguishes them. Co-Authored-By: Claude Opus 4.8 --- apps/tui-go/internal/app/tasks_overlay.go | 28 +++++++++++++++++-- .../tui-go/internal/app/tasks_overlay_test.go | 20 +++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/apps/tui-go/internal/app/tasks_overlay.go b/apps/tui-go/internal/app/tasks_overlay.go index 9c15952e..66c15047 100644 --- a/apps/tui-go/internal/app/tasks_overlay.go +++ b/apps/tui-go/internal/app/tasks_overlay.go @@ -27,6 +27,10 @@ type TaskSummary struct { Notes []TaskNoteWire `json:"notes"` CreatedAt string `json:"createdAt"` UpdatedAt string `json:"updatedAt"` + // Dependency-graph status from the server: Ready = workable now (TODO, no unmet deps); + // BlockedBy = ids of unfinished tasks this one waits on. Absent on older servers → zero values. + Ready bool `json:"ready"` + BlockedBy []string `json:"blockedBy"` } type TaskLinkWire struct { @@ -336,14 +340,29 @@ func (m Model) taskListRow(tasks []TaskSummary, i int) string { if tk.Claimant != "" { claim = " @" + shortSessionID(tk.Claimant) } - titleW := m.modalWidth() - 6 - 12 - 1 - 12 - 1 - len([]rune(claim)) + readyCell := taskReadyCell(t, tk) // 2-col dependency-readiness glyph + titleW := m.modalWidth() - 6 - 12 - 1 - 12 - 1 - 2 - 1 - len([]rune(claim)) if titleW < 8 { titleW = 8 } titleCell := mbg(t, padRaw(truncate(tk.Title, titleW), titleW), t.P.Fg) claimCell := mbg(t, claim, t.P.Faint) - return marker + idCell + " " + statusCell + " " + titleCell + claimCell + return marker + idCell + " " + statusCell + " " + readyCell + " " + titleCell + claimCell +} + +// taskReadyCell is a 2-column glyph showing dependency-graph readiness, so a decomposed graph reads +// at a glance: a filled dot when the task is workable now (TODO, no unmet deps), a hollow dot when it +// is waiting on unfinished blockers, blank for anything already in flight or finished. +func taskReadyCell(t Theme, tk TaskSummary) string { + switch { + case tk.Ready: + return lipgloss.NewStyle().Foreground(t.P.OK).Background(t.P.BgPanel).Render(padRaw("●", 2)) + case len(tk.BlockedBy) > 0: + return lipgloss.NewStyle().Foreground(t.P.Dim).Background(t.P.BgPanel).Render(padRaw("○", 2)) + default: + return mbg(t, " ", t.P.BgPanel) + } } func (m Model) taskDetailModal() string { @@ -407,6 +426,11 @@ func (m Model) taskDetailBody(width int) []string { if tk.Claimant != "" { out = append(out, mbg(t, "claimant: "+tk.Claimant, t.P.Faint)) } + if tk.Ready { + out = append(out, mbg(t, "● ready to work (no unmet dependencies)", t.P.OK)) + } else if len(tk.BlockedBy) > 0 { + out = append(out, mbg(t, "○ blocked — waiting on "+strings.Join(tk.BlockedBy, ", "), t.P.Dim)) + } out = append(out, "") if tk.Goal != "" { diff --git a/apps/tui-go/internal/app/tasks_overlay_test.go b/apps/tui-go/internal/app/tasks_overlay_test.go index 1fb93554..2ccba01b 100644 --- a/apps/tui-go/internal/app/tasks_overlay_test.go +++ b/apps/tui-go/internal/app/tasks_overlay_test.go @@ -148,6 +148,26 @@ func TestTasksFilterNarrowsList(t *testing.T) { } } +func TestTasksReadyAndBlockedSurfaced(t *testing.T) { + m := tasksModel() + m.overlay = OverlayTasks + m.taskList = []TaskSummary{ + {ID: "webui-2", Key: "webui-2", Status: "TODO", Title: "Scaffold app", Ready: true}, + {ID: "webui-3", Key: "webui-3", Status: "TODO", Title: "Auth view", BlockedBy: []string{"webui-2"}}, + } + + // A ready task's detail says so; a blocked task's detail names what it waits on. + m.taskDetail = true + m.taskListIndex = 0 + if out := m.tasksModal(); !strings.Contains(out, "ready to work") { + t.Fatalf("expected ready marker in detail:\n%s", out) + } + m.taskListIndex = 1 + if out := m.tasksModal(); !strings.Contains(out, "waiting on webui-2") { + t.Fatalf("expected blocked-by detail naming the blocker:\n%s", out) + } +} + func TestTasksFetchErrorShownNoPanic(t *testing.T) { m := tasksModel() m.overlay = OverlayTasks From af905a6dad4dfac35aaec605d75c48769501668f Mon Sep 17 00:00:00 2001 From: kami Date: Fri, 26 Jun 2026 10:27:20 +0000 Subject: [PATCH 107/107] feat(tui): task-board preview frames (tasks / task-detail) Add "tasks" and "task-detail" kinds to PreviewFrame, backed by a sampleTaskBoard work graph that exercises every readiness glyph and status color (epic blocked on children, a ready child, in-flight, done). Co-Authored-By: Claude Opus 4.8 --- apps/tui-go/internal/app/demo.go | 46 ++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/apps/tui-go/internal/app/demo.go b/apps/tui-go/internal/app/demo.go index da68ee16..86f3f0e3 100644 --- a/apps/tui-go/internal/app/demo.go +++ b/apps/tui-go/internal/app/demo.go @@ -255,10 +255,56 @@ func PreviewFrame(kind string, w, h int) string { } m.grantIndex = 1 m.overlay = OverlayGrants + + case "tasks", "task-detail": + m.connected = true + m.currentModel = "llama-cpp:default" + m.sessions = sampleSessions() + m.selectedID = "04a546aa" + m.overlay = OverlayTasks + m.taskList = sampleTaskBoard() + if kind == "task-detail" { + m.taskDetail = true + m.taskListIndex = 2 // a blocked child, to show the "waiting on" line + } } return m.render() } +// sampleTaskBoard is a decomposed work graph: an epic waiting on its three +// children, one of which (webui-2) is ready now, plus an in-flight and a done +// task — exercising every readiness glyph and status color. +func sampleTaskBoard() []TaskSummary { + return []TaskSummary{ + { + ID: "webui-1", Key: "webui-1", Status: "TODO", Title: "Frontend web UI for correx", + Goal: "operator can drive sessions from a browser", + BlockedBy: []string{"webui-2", "webui-3", "webui-4"}, + Links: []TaskLinkWire{{TargetID: "webui-2", Type: "DEPENDS_ON", TargetKind: "TASK"}}, + }, + { + ID: "webui-2", Key: "webui-2", Status: "TODO", Title: "Scaffold the Vite + React app", + Goal: "buildable shell with routing", Ready: true, + AcceptanceCriteria: []string{"npm run build passes", "lands under apps/web"}, + AffectedPaths: []string{"apps/web/"}, + }, + { + ID: "webui-3", Key: "webui-3", Status: "TODO", Title: "Auth / token entry view", + Goal: "operator pastes a token and connects", BlockedBy: []string{"webui-2"}, + Links: []TaskLinkWire{{TargetID: "webui-1", Type: "IMPLEMENTS", TargetKind: "TASK"}}, + }, + { + ID: "webui-4", Key: "webui-4", Status: "TODO", Title: "Session board view", + Goal: "live roster mirroring the TUI", BlockedBy: []string{"webui-2"}, + }, + { + ID: "api-7", Key: "api-7", Status: "IN_PROGRESS", Title: "CORS + token endpoint", + Goal: "server accepts browser origins", Claimant: "04a546aa8b2c", + }, + {ID: "api-3", Key: "api-3", Status: "DONE", Title: "Stable /sessions JSON shape"}, + } +} + func sampleStats() *protocol.StatsDto { return &protocol.StatsDto{ SessionID: "04a546aa",