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 cfcf332f..0e32ef16 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 @@ -68,6 +68,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.ShellInFileContentRule import com.correx.core.toolintent.rules.StaleWriteRule import com.correx.core.toolintent.rules.WriteScopeRule import com.correx.apps.server.freestyle.FreestyleDriver @@ -200,15 +201,20 @@ fun main() { ?.let { Path.of(it) } ?: toolsConfig.sandboxRoot.takeIf { it.isNotEmpty() }?.let { Path.of(it) } ?: Paths.get(System.getProperty("user.home"), ".config", "correx", "sandbox") - val workingDir = System.getenv("CORREX_WORKING_DIR") + val explicitWorkingDir = System.getenv("CORREX_WORKING_DIR") ?.let { Path.of(it) } ?: toolsConfig.workingDir.takeIf { it.isNotEmpty() }?.let { Path.of(it) } - ?: Path.of("").toAbsolutePath() - val shellAllowedExecutables = toolsConfig.shellAllowedExecutables.toSet() - val workspaceRoot = System.getenv("CORREX_WORKSPACE_ROOT") + val explicitWorkspaceRoot = System.getenv("CORREX_WORKSPACE_ROOT") ?.let { Path.of(it) } ?: toolsConfig.workspaceRoot.takeIf { it.isNotEmpty() }?.let { Path.of(it) } - ?: workingDir + // workingDir and workspaceRoot must resolve to the same tree by default — the tool-call + // assessor's containment rules (PathContainmentRule, ManifestContainmentRule) only ever see + // workspaceRoot, while FileWriteTool resolves relative paths against workingDir. If only one + // is configured, each falls back to the other before falling back to process CWD, so the + // assessor's containment check and the actual write always share one resolved root. + val shellAllowedExecutables = toolsConfig.shellAllowedExecutables.toSet() + val workspaceRoot = explicitWorkspaceRoot ?: explicitWorkingDir ?: Path.of("").toAbsolutePath() + val workingDir = explicitWorkingDir ?: workspaceRoot // One shared HTTP client backs both the default and per-workspace registries' research tools // (web_search/web_fetch). Built only when research is enabled, so the static path stays offline. val researchToolConfig = com.correx.infrastructure.tools.ResearchToolConfig( @@ -274,6 +280,7 @@ fun main() { PathContainmentRule(), ReadBeforeWriteRule(), ReferenceExistsRule(), + ShellInFileContentRule(), WriteScopeRule(), StaleWriteRule(), ManifestContainmentRule(), @@ -510,6 +517,7 @@ fun main() { config = defaultOrchestrationConfig, runPhase2 = { sid, graph, cfg -> orchestrator.run(sid, graph, cfg) }, requestPlanApproval = { sid, planJson -> orchestrator.requestPlanApproval(sid, planJson) }, + toolCapabilities = toolRegistry.all().associate { it.name to it.requiredCapabilities }, ) // observability-spec §4: continuous health watch. Seed the monitor's last-status from the // recorded system-session events so a restart doesn't re-emit a degraded already in the log. diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/bridge/DomainEventMapper.kt b/apps/server/src/main/kotlin/com/correx/apps/server/bridge/DomainEventMapper.kt index fbdb1fe1..5caab713 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/bridge/DomainEventMapper.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/bridge/DomainEventMapper.kt @@ -340,6 +340,7 @@ private suspend fun mapInferenceCompleted( outputSummary = this, responseText = this, occurredAt = event.metadata.timestamp.toEpochMilliseconds(), + totalTokens = p.tokensUsed.totalTokens, sequence = event.sequence, sessionSequence = sessionSequence, ) diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/bridge/SessionEventBridge.kt b/apps/server/src/main/kotlin/com/correx/apps/server/bridge/SessionEventBridge.kt index 183b0fe9..5f7bd8e1 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/bridge/SessionEventBridge.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/bridge/SessionEventBridge.kt @@ -181,7 +181,7 @@ class SessionEventBridge( ToolDecl(name = tool.name, tier = tool.tier.level) } } - StageToolDecl(stageId = stageId.value, tools = tools) + StageToolDecl(stageId = stageId.value, tools = tools, tokenBudget = stageConfig.tokenBudget) } send(ServerMessage.StageToolManifest( sessionId = sessionId, diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/freestyle/FreestyleDriver.kt b/apps/server/src/main/kotlin/com/correx/apps/server/freestyle/FreestyleDriver.kt index a5483b99..95db9297 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/freestyle/FreestyleDriver.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/freestyle/FreestyleDriver.kt @@ -1,16 +1,20 @@ package com.correx.apps.server.freestyle +import com.correx.core.events.events.CapabilityGapDetectedEvent import com.correx.core.events.events.EventMetadata import com.correx.core.events.events.ExecutionPlanLockedEvent import com.correx.core.events.events.ExecutionPlanRejectedEvent import com.correx.core.events.events.NewEvent import com.correx.core.events.events.PlanLintCompletedEvent +import com.correx.core.tools.contract.ToolCapability +import com.correx.infrastructure.workflow.CapabilityGapDetector import com.correx.infrastructure.workflow.PlanLintResult import com.correx.infrastructure.workflow.PlanLinter 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.core.kernel.execution.WorkflowResult import com.correx.core.kernel.orchestration.OrchestrationConfig import com.correx.core.transitions.graph.WorkflowGraph @@ -26,6 +30,7 @@ import java.util.UUID * [runPhase2] is a functional seam so the driver can be tested without the full * [DefaultSessionOrchestrator] constructor chain. */ +@Suppress("LongParameterList") class FreestyleDriver( private val eventStore: EventStore, private val compiler: ExecutionPlanCompiler, @@ -33,6 +38,10 @@ class FreestyleDriver( private val config: OrchestrationConfig, private val runPhase2: suspend (SessionId, WorkflowGraph, OrchestrationConfig) -> WorkflowResult, private val requestPlanApproval: suspend (SessionId, String) -> Boolean = { _, _ -> true }, + // Tool name -> the capabilities that tool grants (Tool.requiredCapabilities), used only by the + // capability-gap detector below. Empty = detector finds nothing (preserves callers/tests that + // don't care about it) rather than depending on a live ToolRegistry here. + private val toolCapabilities: Map> = emptyMap(), ) { suspend fun lockAndRun(sessionId: SessionId) { val json = planContent(sessionId) ?: run { @@ -59,6 +68,16 @@ class FreestyleDriver( emitRejected(sessionId, "plan failed lint: $summary", "lint") return } + // Capability-gap detector (Vikunja #30 part 1): advisory only — recorded, never blocking. + // A gap does not fail the gate and does not grant the missing tool (invariants #3/#4/#5); a + // future reflection rung (part 2) is what will act on these events. + CapabilityGapDetector.detect(graph, toolCapabilities).forEach { gap -> + log.info( + "[FreestyleDriver] capability gap session={} stage={} implied={} evidence='{}'", + sessionId.value, gap.stageId, gap.impliedCapability, gap.phrase, + ) + emitCapabilityGap(sessionId, gap.stageId, gap.impliedCapability.name, gap.phrase) + } val approved = requestPlanApproval(sessionId, json) if (!approved) { log.info("freestyle: execution plan rejected by operator for session={}", sessionId.value) @@ -121,6 +140,33 @@ class FreestyleDriver( ) } + private suspend fun emitCapabilityGap( + sessionId: SessionId, + stageId: String, + impliedCapability: String, + evidence: String, + ) { + eventStore.append( + NewEvent( + metadata = EventMetadata( + eventId = EventId(UUID.randomUUID().toString()), + sessionId = sessionId, + timestamp = Clock.System.now(), + schemaVersion = 1, + causationId = null, + correlationId = null, + ), + payload = CapabilityGapDetectedEvent( + sessionId = sessionId, + stageId = StageId(stageId), + impliedCapability = impliedCapability, + evidence = evidence, + timestampMs = Clock.System.now().toEpochMilliseconds(), + ), + ), + ) + } + private suspend fun emitRejected(sessionId: SessionId, reason: String, source: String) { eventStore.append( NewEvent( 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 de592440..bbecaeff 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 @@ -86,6 +86,32 @@ sealed class ClientMessage { @Serializable data class UpdateConfig(val patch: Map) : ClientMessage() + /** Operator request for the ProjectProfile bound to [sessionId]'s workspace. */ + @Serializable + data class GetProjectProfile(val sessionId: SessionId) : ClientMessage() + + /** Operator request to overwrite the ProjectProfile at [sessionId]'s workspace root. */ + @Serializable + data class UpdateProjectProfile( + val sessionId: SessionId, + val about: String, + val conventions: List, + val commands: Map, + ) : ClientMessage() + + /** Operator request for the current OperatorProfile (home-scoped, not session-bound). */ + @Serializable + data object GetOperatorProfile : ClientMessage() + + /** Operator request to overwrite the OperatorProfile. */ + @Serializable + data class UpdateOperatorProfile( + val about: String, + val approvalMode: String, + val preferredModels: List, + val conventions: List, + ) : ClientMessage() + @Serializable data class ApprovalResponse( val requestId: ApprovalRequestId, @@ -102,6 +128,18 @@ sealed class ClientMessage { val answers: List, ) : ClientMessage() + /** + * Free-form operator steering for a session that is mid-stage, independent of any approval + * gate. Unlike [ApprovalResponse.steeringNote] (only deliverable while parked on an approval) + * this can be sent at any time; the orchestrator records it and folds it into the stage's next + * context turn as advisory input (never a state mutation — see SessionOrchestrator.submitSteering). + */ + @Serializable + data class SteerSession( + val sessionId: SessionId, + val text: String, + ) : ClientMessage() + @Serializable data class CreateGrant( val sessionId: SessionId, 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 1b3871de..e0b7aae2 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 @@ -57,6 +57,7 @@ enum class PauseReason { data class StageToolDecl( val stageId: String, val tools: List, + val tokenBudget: Int? = null, ) @Serializable 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 7b3e3d85..da415f48 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 @@ -189,6 +189,7 @@ sealed interface ServerMessage { val outputSummary: String, val responseText: String = "", val occurredAt: Long, + val totalTokens: Int? = null, override val sequence: Long, override val sessionSequence: Long, ) : ServerMessage, SessionMessage @@ -546,6 +547,33 @@ sealed interface ServerMessage { override val sessionSequence: Long? = null, ) : ServerMessage, NonEventMessage + /** The ProjectProfile bound to a session's workspace (reply to GetProjectProfile / UpdateProjectProfile). */ + @Serializable + @SerialName("project_profile.snapshot") + data class ProjectProfileSnapshot( + val sessionId: SessionId, + val about: String, + val conventions: List, + val commands: Map, + val error: String? = null, + override val sequence: Long? = null, + override val sessionSequence: Long? = null, + ) : ServerMessage, NonEventMessage + + /** The operator's OperatorProfile (reply to GetOperatorProfile / UpdateOperatorProfile). */ + @Serializable + @SerialName("operator_profile.snapshot") + data class OperatorProfileSnapshot( + val about: String, + val approvalMode: String, + val preferredModels: List, + val conventions: List, + val proposedAdaptation: String? = null, + val error: String? = null, + override val sequence: Long? = null, + override val sessionSequence: Long? = null, + ) : ServerMessage, NonEventMessage + // -- Router -- @Serializable 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 fb69db9f..63fa9577 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 @@ -32,6 +32,11 @@ 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.OperatorProfile +import com.correx.core.config.OperatorProfileWriter +import com.correx.core.config.ProfileLoader +import com.correx.core.config.ProfilePreferences +import com.correx.core.config.ProjectProfile import com.correx.core.config.ProjectProfileLoader import com.correx.core.config.ProjectProfileWriter import com.correx.core.config.withConvention @@ -56,6 +61,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.datetime.Clock import org.slf4j.LoggerFactory +import java.nio.file.Files import java.util.UUID private val log = LoggerFactory.getLogger(GlobalStreamHandler::class.java) @@ -244,9 +250,14 @@ class GlobalStreamHandler(private val module: ServerModule) { is ClientMessage.GetHealthChecks -> sendFrame(queries.healthChecks()) is ClientMessage.GetConfig -> sendFrame(queries.configSnapshot(error = null, restartRequired = emptyList())) is ClientMessage.UpdateConfig -> sendFrame(queries.updateConfig(msg.patch)) + is ClientMessage.GetProjectProfile -> handleGetProjectProfile(msg, sendFrame) + is ClientMessage.UpdateProjectProfile -> handleUpdateProjectProfile(msg, sendFrame) + is ClientMessage.GetOperatorProfile -> handleGetOperatorProfile(sendFrame) + is ClientMessage.UpdateOperatorProfile -> handleUpdateOperatorProfile(msg, sendFrame) is ClientMessage.ResumeSession -> session.send(encodeError("ResumeSession not supported")) is ClientMessage.ApprovalResponse -> handleApprovalResponse(msg, sendFrame) is ClientMessage.ClarificationResponse -> handleClarificationResponse(msg) + is ClientMessage.SteerSession -> handleSteerSession(msg) is ClientMessage.CreateGrant -> handleCreateGrant(msg, sendFrame) is ClientMessage.RevokeGrant -> handleRevokeGrant(msg, sendFrame) is ClientMessage.ListGrants -> sendFrame(queries.listGrants()) @@ -318,6 +329,16 @@ class GlobalStreamHandler(private val module: ServerModule) { } } + // Free-form mid-stage steering, independent of any approval gate — see + // DefaultSessionOrchestrator.submitSteering and ClientMessage.SteerSession. + private suspend fun handleSteerSession(msg: ClientMessage.SteerSession) { + runCatching { + module.orchestrator.submitSteering(msg.sessionId, msg.text) + }.onFailure { + log.error("submitSteering failed for session={}: {}", msg.sessionId.value, it.message, it) + } + } + // Tombstone an idea, landing the discard in the session that captured it (so all of one idea's // events stay co-located), then reply with the refreshed board. A missing idea is a no-op. private suspend fun handleDiscardIdea( @@ -397,6 +418,131 @@ class GlobalStreamHandler(private val module: ServerModule) { .lastOrNull() ?.workspaceRoot + private suspend fun handleGetProjectProfile( + msg: ClientMessage.GetProjectProfile, + sendFrame: suspend (ServerMessage) -> Unit, + ) { + val workspaceRoot = workspaceRootOf(msg.sessionId) + if (workspaceRoot == null) { + sendFrame( + ServerMessage.ProjectProfileSnapshot( + sessionId = msg.sessionId, + about = "", + conventions = emptyList(), + commands = emptyMap(), + error = "session has no bound workspace", + ), + ) + return + } + val profile = withContext(Dispatchers.IO) { ProjectProfileLoader.load(workspaceRoot) } + sendFrame( + ServerMessage.ProjectProfileSnapshot( + sessionId = msg.sessionId, + about = profile.about, + conventions = profile.conventions, + commands = profile.commands, + ), + ) + } + + private suspend fun handleUpdateProjectProfile( + msg: ClientMessage.UpdateProjectProfile, + sendFrame: suspend (ServerMessage) -> Unit, + ) { + val workspaceRoot = workspaceRootOf(msg.sessionId) + if (workspaceRoot == null) { + sendFrame( + ServerMessage.ProjectProfileSnapshot( + sessionId = msg.sessionId, + about = "", + conventions = emptyList(), + commands = emptyMap(), + error = "session has no bound workspace", + ), + ) + return + } + val profile = ProjectProfile(about = msg.about, conventions = msg.conventions, commands = msg.commands) + runCatching { + withContext(Dispatchers.IO) { ProjectProfileWriter.write(workspaceRoot, profile) } + }.onFailure { + log.error("updateProjectProfile failed for session={}: {}", msg.sessionId.value, it.message, it) + sendFrame( + ServerMessage.ProjectProfileSnapshot( + sessionId = msg.sessionId, + about = profile.about, + conventions = profile.conventions, + commands = profile.commands, + error = "failed to save: ${it.message}", + ), + ) + return + } + sendFrame( + ServerMessage.ProjectProfileSnapshot( + sessionId = msg.sessionId, + about = profile.about, + conventions = profile.conventions, + commands = profile.commands, + ), + ) + } + + private suspend fun handleGetOperatorProfile(sendFrame: suspend (ServerMessage) -> Unit) { + val profile = withContext(Dispatchers.IO) { ProfileLoader.load() } + val proposedPath = ProfileLoader.profilePath().resolveSibling("profile.proposed.toml") + val proposed = withContext(Dispatchers.IO) { + runCatching { if (Files.exists(proposedPath)) Files.readString(proposedPath) else null }.getOrNull() + } + sendFrame( + ServerMessage.OperatorProfileSnapshot( + about = profile.about, + approvalMode = profile.preferences.approvalMode, + preferredModels = profile.preferences.preferredModels, + conventions = profile.preferences.conventions, + proposedAdaptation = proposed, + ), + ) + } + + private suspend fun handleUpdateOperatorProfile( + msg: ClientMessage.UpdateOperatorProfile, + sendFrame: suspend (ServerMessage) -> Unit, + ) { + val profile = OperatorProfile( + about = msg.about, + preferences = ProfilePreferences( + approvalMode = msg.approvalMode, + preferredModels = msg.preferredModels, + conventions = msg.conventions, + ), + ) + runCatching { + withContext(Dispatchers.IO) { OperatorProfileWriter.write(profile) } + }.onFailure { + log.error("updateOperatorProfile failed: {}", it.message, it) + sendFrame( + ServerMessage.OperatorProfileSnapshot( + about = profile.about, + approvalMode = profile.preferences.approvalMode, + preferredModels = profile.preferences.preferredModels, + conventions = profile.preferences.conventions, + error = "failed to save: ${it.message}", + ), + ) + return + } + sendFrame( + ServerMessage.OperatorProfileSnapshot( + about = profile.about, + approvalMode = profile.preferences.approvalMode, + preferredModels = profile.preferences.preferredModels, + conventions = profile.preferences.conventions, + ), + ) + } + 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/bridge/DomainEventMapperTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/bridge/DomainEventMapperTest.kt index 577c3044..b622896f 100644 --- a/apps/server/src/test/kotlin/com/correx/apps/server/bridge/DomainEventMapperTest.kt +++ b/apps/server/src/test/kotlin/com/correx/apps/server/bridge/DomainEventMapperTest.kt @@ -260,7 +260,8 @@ class DomainEventMapperTest { val result = domainEventToServerMessage(event, store, sessionSequence = 0L) assertEquals( ServerMessage.InferenceCompleted( - sessionId, stageId, responseText, responseText, occurredAt, event.sequence, 0L, + sessionId, stageId, responseText, responseText, occurredAt, + totalTokens = 15, sequence = event.sequence, sessionSequence = 0L, ), result, ) @@ -281,7 +282,10 @@ class DomainEventMapperTest { ) val result = domainEventToServerMessage(event, noopStore, sessionSequence = 0L) assertEquals( - ServerMessage.InferenceCompleted(sessionId, stageId, "", "", occurredAt, event.sequence, 0L), + ServerMessage.InferenceCompleted( + sessionId, stageId, "", "", occurredAt, + totalTokens = 15, sequence = event.sequence, sessionSequence = 0L, + ), result, ) } diff --git a/apps/tui-go/internal/app/model.go b/apps/tui-go/internal/app/model.go index 16f92e5b..8f4c4b44 100644 --- a/apps/tui-go/internal/app/model.go +++ b/apps/tui-go/internal/app/model.go @@ -64,6 +64,8 @@ const ( OverlayGrants OverlayGrantScope OverlayHelp + OverlayProjectProfile + OverlayOperatorProfile ) // RouterEntry is one line in a session's conversation transcript. @@ -109,6 +111,13 @@ type ManifestTool struct { Tier int } +// ProfileField is one editable row in the project/operator profile editors — a flat +// key/value text field, mirroring the config editor's STRING-field editing model. +type ProfileField struct { + Key string + Value string +} + // Approval is a pending approval gate for a session. type Approval struct { RequestID string @@ -179,18 +188,20 @@ type Proposal struct { // Session is the UI's view of one server session. type Session struct { - ID string - Status string - WorkflowID string - Name string - LastEventAt int64 - CurrentStage string - WorkspaceRoot string // bound cwd, from session.workspace_bound - LastOutput string - LastResponse string - Tools []ToolRecord - ToolsByStage map[string][]ManifestTool - Events []EventEntry + ID string + Status string + WorkflowID string + Name string + LastEventAt int64 + CurrentStage string + WorkspaceRoot string // bound cwd, from session.workspace_bound + LastOutput string + LastResponse string + Tools []ToolRecord + ToolsByStage map[string][]ManifestTool + TokenBudgetByStage map[string]int // stage -> ceiling, from stage.tool_manifest + StageTokensUsed int // tokens used by the most recent inference in CurrentStage + Events []EventEntry // 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. @@ -301,6 +312,29 @@ type Model struct { configRestart []string // keys from the last save that need a restart configLoading bool + // project profile editor (OverlayProjectProfile) — populated by project_profile.snapshot; + // mirrors the config editor's field-list/staged-edit pattern. fields[0] is "about", + // fields[1] is "conventions" (semicolon-joined), the rest are one row per command. + projectProfileFields []ProfileField + projectProfileIndex int + projectProfileStaged map[string]string + projectProfileEditing bool + projectProfileEditBuf string + projectProfileError string + projectProfileLoading bool + + // operator profile editor (OverlayOperatorProfile) — populated by operator_profile.snapshot. + // fields are "about", "approvalMode", "preferredModels" (comma-joined), "conventions" + // (semicolon-joined). proposedAdaptation is read-only (from ProfileAdaptationService). + operatorProfileFields []ProfileField + operatorProfileIndex int + operatorProfileStaged map[string]string + operatorProfileEditing bool + operatorProfileEditBuf string + operatorProfileError string + operatorProfileLoading bool + operatorProfileProposed string + // session stats (OverlayStats) — populated by the session.stats reply stats *protocol.StatsDto statsFor string // sessionId the current stats belong to @@ -413,20 +447,22 @@ type Model struct { // NewModel builds the initial idle state. func NewModel(client *ws.Client) Model { return Model{ - client: client, - theme: NewTheme(SoftBlue), - wfIndex: -1, - inputMode: ModeRouter, - historyIndex: -1, - routerMessages: map[string][]RouterEntry{}, - configStaged: map[string]string{}, - chatMode: ChatModeChat, - providerType: "LOCAL", - snapshotPhase: true, - eventStripShown: true, - transcriptSel: -1, - sbHidden: loadStatusbarHidden(), - actionsHidden: loadPrefs().InlineActionsHidden, + client: client, + theme: NewTheme(SoftBlue), + wfIndex: -1, + inputMode: ModeRouter, + historyIndex: -1, + routerMessages: map[string][]RouterEntry{}, + configStaged: map[string]string{}, + projectProfileStaged: map[string]string{}, + operatorProfileStaged: map[string]string{}, + chatMode: ChatModeChat, + providerType: "LOCAL", + snapshotPhase: true, + eventStripShown: true, + transcriptSel: -1, + sbHidden: loadStatusbarHidden(), + actionsHidden: loadPrefs().InlineActionsHidden, } } diff --git a/apps/tui-go/internal/app/operator_profile_overlay.go b/apps/tui-go/internal/app/operator_profile_overlay.go new file mode 100644 index 00000000..cca2255d --- /dev/null +++ b/apps/tui-go/internal/app/operator_profile_overlay.go @@ -0,0 +1,199 @@ +package app + +import ( + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/correx/tui-go/internal/protocol" +) + +const ( + operatorProfileFieldAbout = "about" + operatorProfileFieldApprovalMode = "approvalMode" + operatorProfileFieldPreferredModels = "preferredModels" + operatorProfileFieldConventions = "conventions" +) + +// openOperatorProfile opens the operator profile editor (home-scoped, not session-bound). +func (m *Model) openOperatorProfile() { + m.overlay = OverlayOperatorProfile + m.operatorProfileIndex = 0 + m.operatorProfileEditing = false + m.operatorProfileEditBuf = "" + m.operatorProfileError = "" + m.operatorProfileStaged = map[string]string{} + m.operatorProfileLoading = true + m.client.Send(protocol.GetOperatorProfile()) +} + +// applyOperatorProfileSnapshot rebuilds the editable field list from an operator_profile.snapshot. +func (m *Model) applyOperatorProfileSnapshot(about, approvalMode string, preferredModels, conventions []string, proposed, errMsg string) { + m.operatorProfileLoading = false + m.operatorProfileError = errMsg + m.operatorProfileProposed = proposed + if errMsg != "" { + return + } + m.operatorProfileFields = []ProfileField{ + {Key: operatorProfileFieldAbout, Value: about}, + {Key: operatorProfileFieldApprovalMode, Value: approvalMode}, + {Key: operatorProfileFieldPreferredModels, Value: strings.Join(preferredModels, ", ")}, + {Key: operatorProfileFieldConventions, Value: strings.Join(conventions, "; ")}, + } + m.operatorProfileStaged = map[string]string{} +} + +// handleOperatorProfileKey owns every key while the operator profile overlay is open. +func (m Model) handleOperatorProfileKey(k keyMsg) (tea.Model, tea.Cmd) { + if m.operatorProfileEditing { + switch k.Type { + case keyEsc: + m.operatorProfileEditing = false + m.operatorProfileEditBuf = "" + case keyEnter: + if m.operatorProfileIndex >= 0 && m.operatorProfileIndex < len(m.operatorProfileFields) { + m.operatorProfileStaged[m.operatorProfileFields[m.operatorProfileIndex].Key] = m.operatorProfileEditBuf + } + m.operatorProfileEditing = false + m.operatorProfileEditBuf = "" + case keyBackspace: + if n := len(m.operatorProfileEditBuf); n > 0 { + m.operatorProfileEditBuf = m.operatorProfileEditBuf[:n-1] + } + case keyRunes, keySpace: + m.operatorProfileEditBuf += string(k.Runes) + } + return m, nil + } + + switch { + case k.Type == keyEsc || runeIs(k, "o"): + m.overlay = OverlayNone + case k.Type == keyUp || runeIs(k, "k"): + if m.operatorProfileIndex > 0 { + m.operatorProfileIndex-- + } + case k.Type == keyDown || runeIs(k, "j"): + if m.operatorProfileIndex < len(m.operatorProfileFields)-1 { + m.operatorProfileIndex++ + } + case k.Type == keyEnter || k.Type == keySpace: + if m.operatorProfileIndex >= 0 && m.operatorProfileIndex < len(m.operatorProfileFields) { + m.operatorProfileEditing = true + m.operatorProfileEditBuf = m.currentOperatorProfileValue(m.operatorProfileFields[m.operatorProfileIndex]) + } + case runeIs(k, "s"): + if len(m.operatorProfileStaged) > 0 { + m = m.saveOperatorProfile() + } + } + return m, nil +} + +// saveOperatorProfile merges staged edits into the field list and sends the whole profile back. +func (m Model) saveOperatorProfile() Model { + var about, approvalMode string + var preferredModels, conventions []string + for _, f := range m.operatorProfileFields { + v := m.currentOperatorProfileValue(f) + switch f.Key { + case operatorProfileFieldAbout: + about = v + case operatorProfileFieldApprovalMode: + approvalMode = v + case operatorProfileFieldPreferredModels: + preferredModels = splitTrimmed(v, ",") + case operatorProfileFieldConventions: + conventions = splitTrimmed(v, ";") + } + } + m.client.Send(protocol.UpdateOperatorProfile(about, approvalMode, preferredModels, conventions)) + return m +} + +func splitTrimmed(s, sep string) []string { + out := []string{} + for _, p := range strings.Split(s, sep) { + p = strings.TrimSpace(p) + if p != "" { + out = append(out, p) + } + } + return out +} + +// currentOperatorProfileValue returns the staged edit for a field if present, else its server value. +func (m Model) currentOperatorProfileValue(f ProfileField) string { + if v, ok := m.operatorProfileStaged[f.Key]; ok { + return v + } + return f.Value +} + +func (m Model) operatorProfileModal() string { + t := m.theme + w := m.modalWidth() + + var b strings.Builder + b.WriteString(m.titleLine("operator profile")) + if n := len(m.operatorProfileStaged); n > 0 { + b.WriteString(mbg(t, " ("+itoa(n)+" unsaved)", t.P.Warn)) + } + b.WriteString("\n\n") + + if m.operatorProfileLoading && len(m.operatorProfileFields) == 0 { + b.WriteString(mbg(t, " loading…", t.P.Faint) + "\n") + b.WriteString("\n" + modalHints(t, [][2]string{{"esc", "close"}})) + return t.Overlay.Width(w).Render(b.String()) + } + if m.operatorProfileError != "" { + b.WriteString(lipgloss.NewStyle().Foreground(t.P.Bad).Background(t.P.BgPanel).Render(" ▲ "+truncate(m.operatorProfileError, w-8)) + "\n\n") + } + + for i, f := range m.operatorProfileFields { + b.WriteString(m.operatorProfileRow(i, f) + "\n") + } + + if m.operatorProfileProposed != "" { + b.WriteString("\n" + mbg(t, " proposed adaptation (review + merge by hand):", t.P.Warn) + "\n") + for _, ln := range wrapLine(m.operatorProfileProposed, w-6) { + b.WriteString(lipgloss.NewStyle().Foreground(t.P.Dim).Background(t.P.BgPanel).Render(" "+ln) + "\n") + } + } + + b.WriteString("\n" + modalHints(t, [][2]string{ + {"↑↓", "select"}, {"enter", "edit"}, {"s", "save"}, {"o/esc", "close"}, + })) + return t.Overlay.Width(w).Render(b.String()) +} + +func (m Model) operatorProfileRow(i int, f ProfileField) string { + t := m.theme + _, staged := m.operatorProfileStaged[f.Key] + + marker := mbg(t, " ", t.P.BgPanel) + keyFg := t.P.Fg + if i == m.operatorProfileIndex { + marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▸ ") + keyFg = t.P.FgStrong + } + + var valStr string + if m.operatorProfileEditing && i == m.operatorProfileIndex { + caret := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▏") + valStr = lipgloss.NewStyle().Foreground(t.P.FgStrong).Background(t.P.BgPanel).Render(m.operatorProfileEditBuf) + caret + } else { + valFg := t.P.Accent2 + if staged { + valFg = t.P.Warn + } + val := m.currentOperatorProfileValue(f) + if staged { + val = "*" + val + } + valStr = lipgloss.NewStyle().Foreground(valFg).Background(t.P.BgPanel).Render(val) + } + + return marker + lipgloss.NewStyle().Foreground(keyFg).Background(t.P.BgPanel).Render(padRaw(f.Key, 20)) + " " + valStr +} diff --git a/apps/tui-go/internal/app/overlays.go b/apps/tui-go/internal/app/overlays.go index dd175e2f..5d2c9981 100644 --- a/apps/tui-go/internal/app/overlays.go +++ b/apps/tui-go/internal/app/overlays.go @@ -119,6 +119,10 @@ func (m Model) renderOverlay(base string) string { return m.center(m.artifactsModal()) case OverlayConfig: return m.center(m.configModal()) + case OverlayProjectProfile: + return m.center(m.projectProfileModal()) + case OverlayOperatorProfile: + return m.center(m.operatorProfileModal()) case OverlayStats: return m.center(m.statsModal()) case OverlayIdeas: @@ -160,7 +164,11 @@ func (m Model) approvalBandHeight() int { if s := m.session(m.selectedID); s != nil && s.Pending != nil { rows = m.approvalContentRows(s.Pending) if n := len(s.Pending.Rationale); n > 0 { - rat = n + 1 // rationale lines + trailing blank + textW := m.width - 4 - 2 // box borders/padding + rationale marker + for _, r := range s.Pending.Rationale { + rat += len(wrapLine(r, textW)) + } + rat++ // trailing blank } } steer := 0 @@ -219,7 +227,10 @@ func (m Model) renderApprovalBand(h int) string { innerH := h - 2 ratBlock := 0 if len(a.Rationale) > 0 { - ratBlock = len(a.Rationale) + 1 // rationale lines + trailing blank + for _, r := range a.Rationale { + ratBlock += len(wrapLine(r, textW-2)) + } + ratBlock++ // trailing blank } steerBlock := 0 if m.steering || m.steerBuffer != "" { @@ -233,7 +244,13 @@ func (m Model) renderApprovalBand(h int) string { body = append(body, m.justify(leftHdr, rightHdr, textW, t.P.Bg), "") for _, r := range a.Rationale { marker := lipgloss.NewStyle().Foreground(t.P.Warn).Background(t.P.Bg).Render("▲ ") - body = append(body, marker+t.span(truncate(r, textW-2), t.P.Dim)) + for i, ln := range wrapLine(r, textW-2) { + prefix := marker + if i > 0 { + prefix = " " + } + body = append(body, prefix+t.span(ln, t.P.Dim)) + } } if len(a.Rationale) > 0 { body = append(body, "") diff --git a/apps/tui-go/internal/app/project_profile_overlay.go b/apps/tui-go/internal/app/project_profile_overlay.go new file mode 100644 index 00000000..eac01813 --- /dev/null +++ b/apps/tui-go/internal/app/project_profile_overlay.go @@ -0,0 +1,194 @@ +package app + +import ( + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/correx/tui-go/internal/protocol" +) + +const ( + projectProfileFieldAbout = "about" + projectProfileFieldConventions = "conventions" +) + +// openProjectProfile opens the project profile editor for the selected session's bound workspace. +func (m *Model) openProjectProfile() { + s := m.session(m.selectedID) + if s == nil { + return + } + m.overlay = OverlayProjectProfile + m.projectProfileIndex = 0 + m.projectProfileEditing = false + m.projectProfileEditBuf = "" + m.projectProfileError = "" + m.projectProfileStaged = map[string]string{} + m.projectProfileLoading = true + m.client.Send(protocol.GetProjectProfile(s.ID)) +} + +// applyProjectProfileSnapshot rebuilds the editable field list from a project_profile.snapshot. +func (m *Model) applyProjectProfileSnapshot(about string, conventions []string, commands map[string]string, errMsg string) { + m.projectProfileLoading = false + m.projectProfileError = errMsg + if errMsg != "" { + return + } + fields := []ProfileField{ + {Key: projectProfileFieldAbout, Value: about}, + {Key: projectProfileFieldConventions, Value: strings.Join(conventions, "; ")}, + } + for k, v := range commands { + fields = append(fields, ProfileField{Key: k, Value: v}) + } + m.projectProfileFields = fields + m.projectProfileStaged = map[string]string{} +} + +// handleProjectProfileKey owns every key while the project profile overlay is open. +func (m Model) handleProjectProfileKey(k keyMsg) (tea.Model, tea.Cmd) { + if m.projectProfileEditing { + switch k.Type { + case keyEsc: + m.projectProfileEditing = false + m.projectProfileEditBuf = "" + case keyEnter: + if m.projectProfileIndex >= 0 && m.projectProfileIndex < len(m.projectProfileFields) { + m.projectProfileStaged[m.projectProfileFields[m.projectProfileIndex].Key] = m.projectProfileEditBuf + } + m.projectProfileEditing = false + m.projectProfileEditBuf = "" + case keyBackspace: + if n := len(m.projectProfileEditBuf); n > 0 { + m.projectProfileEditBuf = m.projectProfileEditBuf[:n-1] + } + case keyRunes, keySpace: + m.projectProfileEditBuf += string(k.Runes) + } + return m, nil + } + + switch { + case k.Type == keyEsc || runeIs(k, "p"): + m.overlay = OverlayNone + case k.Type == keyUp || runeIs(k, "k"): + if m.projectProfileIndex > 0 { + m.projectProfileIndex-- + } + case k.Type == keyDown || runeIs(k, "j"): + if m.projectProfileIndex < len(m.projectProfileFields)-1 { + m.projectProfileIndex++ + } + case k.Type == keyEnter || k.Type == keySpace: + if m.projectProfileIndex >= 0 && m.projectProfileIndex < len(m.projectProfileFields) { + m.projectProfileEditing = true + m.projectProfileEditBuf = m.currentProjectProfileValue(m.projectProfileFields[m.projectProfileIndex]) + } + case runeIs(k, "s"): + if len(m.projectProfileStaged) > 0 { + m = m.saveProjectProfile() + } + } + return m, nil +} + +// saveProjectProfile merges staged edits into the field list and sends the whole profile back. +func (m Model) saveProjectProfile() Model { + s := m.session(m.selectedID) + if s == nil { + return m + } + about := "" + conventions := []string{} + commands := map[string]string{} + for _, f := range m.projectProfileFields { + v := m.currentProjectProfileValue(f) + switch f.Key { + case projectProfileFieldAbout: + about = v + case projectProfileFieldConventions: + for _, c := range strings.Split(v, ";") { + c = strings.TrimSpace(c) + if c != "" { + conventions = append(conventions, c) + } + } + default: + if v != "" { + commands[f.Key] = v + } + } + } + m.client.Send(protocol.UpdateProjectProfile(s.ID, about, conventions, commands)) + return m +} + +// currentProjectProfileValue returns the staged edit for a field if present, else its server value. +func (m Model) currentProjectProfileValue(f ProfileField) string { + if v, ok := m.projectProfileStaged[f.Key]; ok { + return v + } + return f.Value +} + +func (m Model) projectProfileModal() string { + t := m.theme + w := m.modalWidth() + + var b strings.Builder + b.WriteString(m.titleLine("project profile")) + if n := len(m.projectProfileStaged); n > 0 { + b.WriteString(mbg(t, " ("+itoa(n)+" unsaved)", t.P.Warn)) + } + b.WriteString("\n\n") + + if m.projectProfileLoading && len(m.projectProfileFields) == 0 { + b.WriteString(mbg(t, " loading…", t.P.Faint) + "\n") + b.WriteString("\n" + modalHints(t, [][2]string{{"esc", "close"}})) + return t.Overlay.Width(w).Render(b.String()) + } + if m.projectProfileError != "" { + b.WriteString(lipgloss.NewStyle().Foreground(t.P.Bad).Background(t.P.BgPanel).Render(" ▲ "+truncate(m.projectProfileError, w-8)) + "\n\n") + } + + for i, f := range m.projectProfileFields { + b.WriteString(m.projectProfileRow(i, f) + "\n") + } + + b.WriteString("\n" + modalHints(t, [][2]string{ + {"↑↓", "select"}, {"enter", "edit"}, {"s", "save"}, {"p/esc", "close"}, + })) + return t.Overlay.Width(w).Render(b.String()) +} + +func (m Model) projectProfileRow(i int, f ProfileField) string { + t := m.theme + _, staged := m.projectProfileStaged[f.Key] + + marker := mbg(t, " ", t.P.BgPanel) + keyFg := t.P.Fg + if i == m.projectProfileIndex { + marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▸ ") + keyFg = t.P.FgStrong + } + + var valStr string + if m.projectProfileEditing && i == m.projectProfileIndex { + caret := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▏") + valStr = lipgloss.NewStyle().Foreground(t.P.FgStrong).Background(t.P.BgPanel).Render(m.projectProfileEditBuf) + caret + } else { + valFg := t.P.Accent2 + if staged { + valFg = t.P.Warn + } + val := m.currentProjectProfileValue(f) + if staged { + val = "*" + val + } + valStr = lipgloss.NewStyle().Foreground(valFg).Background(t.P.BgPanel).Render(val) + } + + return marker + lipgloss.NewStyle().Foreground(keyFg).Background(t.P.BgPanel).Render(padRaw(f.Key, 20)) + " " + valStr +} diff --git a/apps/tui-go/internal/app/server.go b/apps/tui-go/internal/app/server.go index d5773541..c7d14c3b 100644 --- a/apps/tui-go/internal/app/server.go +++ b/apps/tui-go/internal/app/server.go @@ -151,6 +151,7 @@ func (m *Model) applyServer(msg protocol.ServerMessage) { if s := m.session(msg.SessionID); s != nil { s.CurrentStage = msg.StageID s.Tools = nil + s.StageTokensUsed = 0 s.addEvent(msg.OccurredAt, "StageStarted", msg.StageID) } case protocol.TypeStageCompleted: @@ -175,6 +176,9 @@ func (m *Model) applyServer(msg protocol.ServerMessage) { if msg.Response != "" { s.LastResponse = msg.Response } + if msg.TotalTokens != nil { + s.StageTokensUsed = *msg.TotalTokens + } s.addEvent(msg.OccurredAt, "InferenceCompleted", msg.StageID) } case protocol.TypeInferenceTimeout: @@ -292,15 +296,36 @@ func (m *Model) applyServer(msg protocol.ServerMessage) { case protocol.TypeStageManifest: if s := m.session(msg.SessionID); s != nil { byStage := map[string][]ManifestTool{} + budgetByStage := map[string]int{} for _, st := range msg.Stages { tools := make([]ManifestTool, 0, len(st.Tools)) for _, td := range st.Tools { tools = append(tools, ManifestTool{Name: td.Name, Tier: td.Tier}) } byStage[st.StageID] = tools + if st.TokenBudget != nil { + budgetByStage[st.StageID] = *st.TokenBudget + } } s.ToolsByStage = byStage + s.TokenBudgetByStage = budgetByStage } + case protocol.TypeProjectProfile: + errMsg := "" + if msg.ConfigError != nil { + errMsg = *msg.ConfigError + } + m.applyProjectProfileSnapshot(msg.About, msg.Conventions, msg.Commands, errMsg) + case protocol.TypeOperatorProfile: + errMsg := "" + if msg.ConfigError != nil { + errMsg = *msg.ConfigError + } + proposed := "" + if msg.ProposedAdaptation != nil { + proposed = *msg.ProposedAdaptation + } + m.applyOperatorProfileSnapshot(msg.About, msg.ApprovalMode, msg.PreferredModels, msg.Conventions, proposed, errMsg) case protocol.TypeProviderStatus: m.currentModel = msg.ProviderID if containsFold(msg.ProviderID, "llama") || containsFold(msg.ProviderID, "local") { @@ -411,7 +436,7 @@ func sessionIDOf(msg protocol.ServerMessage) string { protocol.TypeModelChanged, protocol.TypeModelList, protocol.TypeResourceStatus, protocol.TypeArtifactList, protocol.TypeConfigSnapshot, protocol.TypeSessionStats, protocol.TypeIdeaList, protocol.TypeHealthChecks, - protocol.TypeFileList, protocol.TypeGrantList: + protocol.TypeFileList, protocol.TypeGrantList, protocol.TypeOperatorProfile: return "" default: return msg.SessionID diff --git a/apps/tui-go/internal/app/update.go b/apps/tui-go/internal/app/update.go index 6238b050..ca62fdec 100644 --- a/apps/tui-go/internal/app/update.go +++ b/apps/tui-go/internal/app/update.go @@ -324,6 +324,10 @@ func (m Model) handleNormalKey(k keyMsg) (tea.Model, tea.Cmd) { m.openGrants() case "g": m.openConfig() + case "P": + m.openProjectProfile() + case "O": + m.openOperatorProfile() case "m": m.openModelsOverlay() case "w": @@ -658,6 +662,13 @@ func (m Model) handleOverlayKey(k keyMsg) (tea.Model, tea.Cmd) { if m.overlay == OverlayConfig { return m.handleConfigKey(k) } + // Project/operator profile overlays own all their keys, same reason as config. + if m.overlay == OverlayProjectProfile { + return m.handleProjectProfileKey(k) + } + if m.overlay == OverlayOperatorProfile { + return m.handleOperatorProfileKey(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 { diff --git a/apps/tui-go/internal/app/view.go b/apps/tui-go/internal/app/view.go index 819b36c8..e0e0eb4f 100644 --- a/apps/tui-go/internal/app/view.go +++ b/apps/tui-go/internal/app/view.go @@ -182,7 +182,11 @@ 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 != "" && m.sbShow("stage") { - parts = append(parts, span("⟐ "+s.CurrentStage, t.P.Accent2)) + stageLabel := "⟐ " + s.CurrentStage + if budget, ok := s.TokenBudgetByStage[s.CurrentStage]; ok && budget > 0 { + stageLabel += fmt.Sprintf(" (%d/%d)", s.StageTokensUsed, budget) + } + parts = append(parts, span(stageLabel, t.P.Accent2)) } if s.Status != "" && m.sbShow("status") { parts = append(parts, lipgloss.NewStyle().Foreground(statusColor(t, s.Status)).Background(bg).Render(statusLabel(s.Status))) diff --git a/apps/tui-go/internal/protocol/protocol.go b/apps/tui-go/internal/protocol/protocol.go index b6787cc3..054d2ed3 100644 --- a/apps/tui-go/internal/protocol/protocol.go +++ b/apps/tui-go/internal/protocol/protocol.go @@ -53,6 +53,8 @@ const ( TypeRouterNarration = "router.narration" TypeArtifactList = "artifact.list" TypeConfigSnapshot = "config.snapshot" + TypeProjectProfile = "project_profile.snapshot" + TypeOperatorProfile = "operator_profile.snapshot" TypeSessionStats = "session.stats" TypeIdeaList = "idea.list" TypeHealthChecks = "health.checks" @@ -139,6 +141,15 @@ type ServerMessage struct { ConfigRestartRequired []string `json:"restartRequired"` ConfigError *string `json:"error"` + // project_profile.snapshot / operator_profile.snapshot (About/Conventions shared; Commands is + // project-only, ApprovalMode/PreferredModels/ProposedAdaptation are operator-only) + About string `json:"about"` + Conventions []string `json:"conventions"` + Commands map[string]string `json:"commands"` + ApprovalMode string `json:"approvalMode"` + PreferredModels []string `json:"preferredModels"` + ProposedAdaptation *string `json:"proposedAdaptation"` + // session.stats — derived metrics for a session (reply to GetSessionStats) Stats *StatsDto `json:"stats"` @@ -334,8 +345,9 @@ type ApprovalDto struct { } type StageToolDecl struct { - StageID string `json:"stageId"` - Tools []ToolDecl `json:"tools"` + StageID string `json:"stageId"` + Tools []ToolDecl `json:"tools"` + TokenBudget *int `json:"tokenBudget"` } type ToolDecl struct { @@ -460,6 +472,38 @@ func UpdateConfig(patch map[string]string) []byte { return encode("UpdateConfig", map[string]any{"patch": patch}) } +// GetProjectProfile requests the ProjectProfile bound to a session's workspace (replied to with +// a project_profile.snapshot). +func GetProjectProfile(sessionID string) []byte { + return encode("GetProjectProfile", map[string]any{"sessionId": sessionID}) +} + +// UpdateProjectProfile overwrites the ProjectProfile at a session's workspace root. +func UpdateProjectProfile(sessionID, about string, conventions []string, commands map[string]string) []byte { + return encode("UpdateProjectProfile", map[string]any{ + "sessionId": sessionID, + "about": about, + "conventions": conventions, + "commands": commands, + }) +} + +// GetOperatorProfile requests the operator's OperatorProfile (replied to with an +// operator_profile.snapshot). Not session-scoped. +func GetOperatorProfile() []byte { + return encode("GetOperatorProfile", map[string]any{}) +} + +// UpdateOperatorProfile overwrites the OperatorProfile. +func UpdateOperatorProfile(about, approvalMode string, preferredModels, conventions []string) []byte { + return encode("UpdateOperatorProfile", map[string]any{ + "about": about, + "approvalMode": approvalMode, + "preferredModels": preferredModels, + "conventions": conventions, + }) +} + // ApprovalResponse answers an approval gate. decision is "APPROVE" or "REJECT". func ApprovalResponse(requestID, decision string, steeringNote *string) []byte { return encode("ApprovalResponse", map[string]any{ diff --git a/core/artifacts/src/main/kotlin/com/correx/core/artifacts/kind/JsonSchema.kt b/core/artifacts/src/main/kotlin/com/correx/core/artifacts/kind/JsonSchema.kt index f6e877bb..b29b74c1 100644 --- a/core/artifacts/src/main/kotlin/com/correx/core/artifacts/kind/JsonSchema.kt +++ b/core/artifacts/src/main/kotlin/com/correx/core/artifacts/kind/JsonSchema.kt @@ -21,4 +21,13 @@ data class JsonSchemaProperty( // array is a misread brief, not a valid artifact). val minItems: Int = 0, val items: JsonSchemaProperty? = null, + // For `type == "object"` (either a nested property or an array's `items`): the object's + // own shape. Lets a schema describe structured collections — e.g. the discovery stage's + // `questions: array of { prompt, options, header, ... }`. Without this the parser rejects + // any nested object schema ("unknown key 'properties'"), silently dropping the kind. + // JSON-schema convention: nested objects allow extra keys by default (additionalProperties=true), + // unlike the top-level [JsonSchema] which defaults to strict. + val properties: Map = emptyMap(), + val required: List = emptyList(), + val additionalProperties: Boolean = true, ) diff --git a/core/config/src/main/kotlin/com/correx/core/config/OperatorProfileWriter.kt b/core/config/src/main/kotlin/com/correx/core/config/OperatorProfileWriter.kt new file mode 100644 index 00000000..206f066f --- /dev/null +++ b/core/config/src/main/kotlin/com/correx/core/config/OperatorProfileWriter.kt @@ -0,0 +1,48 @@ +package com.correx.core.config + +import java.nio.file.Files + +/** + * Serializes an [OperatorProfile] back to TOML text that [ProfileLoader] reads identically, + * mirroring [ProjectProfileWriter]'s regenerate-the-file, skip-empty-sections approach. + */ +object OperatorProfileWriter { + + /** Renders [profile] as TOML. String values escape `\` and `"`; empty sections are skipped. */ + fun serialize(profile: OperatorProfile): String { + val b = StringBuilder() + + if (profile.about.isNotBlank()) { + b.append("about = ").append(str(profile.about)).append('\n') + } + + val prefs = profile.preferences + val hasPrefs = prefs.approvalMode.isNotBlank() || prefs.preferredModels.isNotEmpty() || prefs.conventions.isNotEmpty() + if (hasPrefs) { + if (b.isNotEmpty()) b.append('\n') + b.append("[preferences]\n") + if (prefs.approvalMode.isNotBlank()) { + b.append("approval_mode = ").append(str(prefs.approvalMode)).append('\n') + } + if (prefs.preferredModels.isNotEmpty()) { + b.append("preferred_models = ").append(list(prefs.preferredModels)).append('\n') + } + if (prefs.conventions.isNotEmpty()) { + b.append("conventions = ").append(list(prefs.conventions)).append('\n') + } + } + + return b.toString() + } + + /** Writes [profile] to `/.config/correx/profile.toml`, creating parent dirs if missing. */ + fun write(profile: OperatorProfile, homeDir: String = System.getProperty("user.home")) { + val path = ProfileLoader.profilePath(homeDir) + 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) } + "]" +} diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/ExecutionPlanEvents.kt b/core/events/src/main/kotlin/com/correx/core/events/events/ExecutionPlanEvents.kt index 4f261326..7384fc1a 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/events/ExecutionPlanEvents.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/events/ExecutionPlanEvents.kt @@ -52,3 +52,24 @@ data class PlanCompileCheckedEvent( /** Compiler error when [passed] is false; null on success. Kept bounded. */ val error: String? = null, ) : EventPayload + +/** + * Capability-gap detector finding (Vikunja #30 part 1): a stage's declared intent implies a tool + * capability ([impliedCapability]) it was not granted, per the deterministic verb map in + * [com.correx.infrastructure.workflow.CapabilityGapDetector]. [evidence] is the triggering phrase + * found in the stage brief. + * + * Advisory only — recorded alongside the plan-compile gate's other findings but does NOT fail the + * gate and does NOT grant the tool itself (invariants #3 LLM-proposes/core-decides, #4 + * policy-absolute, #5 tiers-declared-not-inferred). A future reflection rung (part 2, an LLM + * "are you sure?" pass) consumes these events; this ticket stops at detection + recording. + */ +@Serializable +@SerialName("CapabilityGapDetected") +data class CapabilityGapDetectedEvent( + val sessionId: SessionId, + val stageId: StageId, + val impliedCapability: String, + val evidence: String, + val timestampMs: Long, +) : EventPayload diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/InferenceEvents.kt b/core/events/src/main/kotlin/com/correx/core/events/events/InferenceEvents.kt index bb4685f5..e6ca7539 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/events/InferenceEvents.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/events/InferenceEvents.kt @@ -30,6 +30,7 @@ data class InferenceCompletedEvent( val tokensUsed: TokenUsage, val latencyMs: Long, val responseArtifactId: ArtifactId, + val reasoningArtifactId: ArtifactId? = null, ) : EventPayload @Serializable 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 79a813c2..e905aef7 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 @@ -34,6 +34,7 @@ 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.PlanCompileCheckedEvent +import com.correx.core.events.events.CapabilityGapDetectedEvent import com.correx.core.events.events.ReviewFindingsRaisedEvent import com.correx.core.events.events.ExecutionPlanRejectedEvent import com.correx.core.events.events.HealthDegradedEvent @@ -171,6 +172,7 @@ val eventModule = SerializersModule { subclass(ExecutionPlanLockedEvent::class) subclass(ExecutionPlanRejectedEvent::class) subclass(PlanCompileCheckedEvent::class) + subclass(CapabilityGapDetectedEvent::class) subclass(ReviewFindingsRaisedEvent::class) subclass(JournalCompactedEvent::class) subclass(HealthDegradedEvent::class) 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 bcac8dfb..58bfb4cc 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 @@ -20,6 +20,7 @@ import com.correx.core.events.events.OrchestrationResumedEvent import com.correx.core.events.events.RefinementIterationEvent import com.correx.core.events.events.RetrySalvageDecidedEvent import com.correx.core.events.events.SalvageDecision +import com.correx.core.events.events.SteeringNoteAddedEvent import com.correx.core.events.events.StoredEvent import com.correx.core.events.events.TransitionExecutedEvent import com.correx.core.events.orchestration.OrchestrationState @@ -469,6 +470,30 @@ class DefaultSessionOrchestrator( emit(sessionId, OrchestrationResumedEvent(sessionId, stageId)) } + /** + * Records free-form operator steering for a session that is mid-stage — independent of any + * approval gate. Unlike [submitApprovalDecision]/[submitClarification] this never completes a + * pending deferred: the running stage is not paused waiting on it. It only appends a + * [com.correx.core.events.events.SteeringNoteAddedEvent], which [buildSteeringNoteEntries] folds + * into context as an advisory `steeringNote` entry on the *next* context turn (invariants #3/#7 — + * advisory only, never a state mutation). Reuses the existing SteeringNoteAddedEvent/fold rather + * than a parallel event type, since that mechanism already implements exactly this semantics + * (see TalkieFacade's STEERING chat mode, which emits the same event). + */ + suspend fun submitSteering(sessionId: SessionId, text: String) { + if (text.isBlank()) return + val currentStageId = runCatching { orchestrationRepository.getState(sessionId).currentStageId } + .getOrNull() + emit( + sessionId, + SteeringNoteAddedEvent( + sessionId = sessionId, + content = text, + stageId = currentStageId, + ), + ) + } + private suspend fun executeMove( ctx: EnrichedExecutionContext, decision: TransitionDecision.Move, diff --git a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/ShellInFileContentRule.kt b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/ShellInFileContentRule.kt new file mode 100644 index 00000000..f880dcba --- /dev/null +++ b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/ShellInFileContentRule.kt @@ -0,0 +1,89 @@ +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 + +/** + * Shell-command-as-file-content gate. Dispatches on FILE_WRITE: a model lacking a shell tool has + * been observed "faking" one by writing a file whose entire content is a shell command (e.g. a file + * named `tasks` containing only `mkdir -p frontend/src/components/tasks`) instead of learning that + * file_write already creates parent directories. The heuristic is deliberately narrow to avoid + * flagging legitimate single-line files: it only fires when the content is a single short line that + * *starts* with a directory/file-management shell verb, AND the write target's extension is one that + * would never legitimately hold a bare shell command (source/doc/data files) — shell scripts + * (`.sh`/`.bash`/etc.), known build-tool files by basename (e.g. `Makefile`, `Dockerfile`), and any + * multi-line content are exempt. + */ +class ShellInFileContentRule : ToolCallRule { + + override fun appliesTo(capabilities: Set): Boolean = + ToolCapability.FILE_WRITE in capabilities + + override fun assess(input: ToolCallAssessmentInput): ToolCallAssessment { + val pathString = input.request.parameters["path"] as? String + val content = input.request.parameters["content"] as? String + + if (pathString == null || content == null) { + return ToolCallAssessment() + } + + val trimmed = content.trim() + val isSingleLine = trimmed.isNotBlank() && !trimmed.contains('\n') + val leadingVerb = SHELL_VERBS.firstOrNull { verb -> + trimmed.startsWith(verb) && (trimmed.length == verb.length || trimmed[verb.length].isWhitespace()) + } + val fileName = pathString.substringAfterLast('/') + val extension = fileName.substringAfterLast('.', missingDelimiterValue = "") + val isExemptTarget = extension.lowercase() in EXEMPT_EXTENSIONS || + fileName.lowercase() in EXEMPT_BASENAMES + + val looksLikeShellCommand = isSingleLine && leadingVerb != null && !isExemptTarget + + val observations = listOf( + ToolCallObservation( + ruleCode = RULE_CODE, + facts = mapOf( + "path" to pathString, + "singleLine" to isSingleLine.toString(), + "leadingVerb" to (leadingVerb ?: ""), + "extension" to extension, + "flagged" to looksLikeShellCommand.toString(), + ), + ), + ) + + if (!looksLikeShellCommand) { + return ToolCallAssessment(observations = observations) + } + + val issue = ValidationIssue( + code = RULE_CODE, + message = "[$RULE_CODE] file_write content looks like a shell command " + + "('${trimmed.take(SNIPPET_LENGTH)}'), not file content. To create a directory, just " + + "write your file at the nested path — parents are auto-created. Do not write shell " + + "commands into files.", + severity = ValidationSeverity.ERROR, + ) + + return ToolCallAssessment( + issues = listOf(issue), + observations = observations, + disposition = maxAction(RiskAction.PROCEED, RiskAction.BLOCK), + ) + } + + private companion object { + const val RULE_CODE = "SHELL_IN_FILE" + const val SNIPPET_LENGTH = 60 + val SHELL_VERBS = listOf("mkdir", "rm", "cp", "mv", "touch", "cat", "echo", "chmod", "ls") + val EXEMPT_EXTENSIONS = setOf("sh", "bash", "zsh", "ps1", "bat", "cmd") + val EXEMPT_BASENAMES = setOf("makefile", "dockerfile", "vagrantfile", "rakefile", "gemfile", "procfile") + } +} diff --git a/core/toolintent/src/test/kotlin/com/correx/core/toolintent/ShellInFileContentRuleTest.kt b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/ShellInFileContentRuleTest.kt new file mode 100644 index 00000000..d5cb00e5 --- /dev/null +++ b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/ShellInFileContentRuleTest.kt @@ -0,0 +1,85 @@ +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.ShellInFileContentRule +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 ShellInFileContentRuleTest { + + private val workspace = Path.of("/work/project") + private val rule = ShellInFileContentRule() + + private class FakeProbe : WorldProbe { + override fun exists(path: Path) = true + override fun resolveReal(path: Path): Path = path.toAbsolutePath().normalize() + } + + private fun input(pathArg: String, content: String) = ToolCallAssessmentInput( + request = ToolRequest( + ToolInvocationId("i"), + SessionId("s"), + StageId("st"), + "file_write", + mapOf("path" to pathArg, "content" to content), + ), + capabilities = setOf(ToolCapability.FILE_WRITE), + workspace = WorkspacePolicy(workspace, emptyList()), + probe = FakeProbe(), + ) + + @Test + fun `applies only to FILE_WRITE`() { + assertTrue(rule.appliesTo(setOf(ToolCapability.FILE_WRITE))) + assertFalse(rule.appliesTo(setOf(ToolCapability.FILE_READ))) + } + + @Test + fun `a source file whose content is a bare mkdir command is blocked`() { + val r = rule.assess(input("frontend/src/components/tasks", "mkdir -p frontend/src/components/tasks")) + assertEquals(RiskAction.BLOCK, r.disposition) + assertEquals("SHELL_IN_FILE", r.issues.single().code) + assertTrue(r.issues.single().message.contains("parents are auto-created")) + } + + @Test + fun `a real tsx file proceeds`() { + val content = "export function Tasks() {\n return
tasks
;\n}\n" + val r = rule.assess(input("frontend/src/components/tasks.tsx", content)) + assertEquals(RiskAction.PROCEED, r.disposition) + assertTrue(r.issues.isEmpty()) + } + + @Test + fun `a real shell script starting with a shell verb is exempt`() { + val r = rule.assess(input("scripts/setup.sh", "mkdir -p build\ncp foo build/")) + assertEquals(RiskAction.PROCEED, r.disposition) + } + + @Test + fun `a single-line shell script is exempt by extension`() { + val r = rule.assess(input("scripts/mk.sh", "mkdir -p build")) + assertEquals(RiskAction.PROCEED, r.disposition) + } + + @Test + fun `a README with a multi-line mkdir code block proceeds`() { + val content = "# Setup\n\n```sh\nmkdir -p build\n```\n" + val r = rule.assess(input("README.md", content)) + assertEquals(RiskAction.PROCEED, r.disposition) + } + + @Test + fun `an extensionless Makefile is exempt`() { + val r = rule.assess(input("Makefile", "mkdir -p build")) + assertEquals(RiskAction.PROCEED, r.disposition) + } +} diff --git a/core/validation/src/main/kotlin/com/correx/core/validation/artifact/JsonSchemaValidator.kt b/core/validation/src/main/kotlin/com/correx/core/validation/artifact/JsonSchemaValidator.kt index 10dcda94..10d63a54 100644 --- a/core/validation/src/main/kotlin/com/correx/core/validation/artifact/JsonSchemaValidator.kt +++ b/core/validation/src/main/kotlin/com/correx/core/validation/artifact/JsonSchemaValidator.kt @@ -50,23 +50,49 @@ object JsonSchemaValidator { private fun checkProperty(key: String, property: JsonSchemaProperty, element: JsonElement): List { val typeError = checkType(property.type, element) - val array = element as? JsonArray return when { typeError != null -> listOf("property '$key': $typeError") - property.type != "array" || array == null -> emptyList() - else -> buildList { - if (array.size < property.minItems) { - add("property '$key': expected at least ${property.minItems} item(s), got ${array.size}") - } - property.items?.let { itemSchema -> - array.forEachIndexed { i, el -> - checkType(itemSchema.type, el)?.let { add("property '$key'[$i]: $it") } - } + property.type == "array" -> checkArray(key, property, element as JsonArray) + property.type == "object" && property.properties.isNotEmpty() -> + validateProperty(property, element as JsonObject).map { "property '$key': $it" } + else -> emptyList() + } + } + + private fun checkArray(key: String, property: JsonSchemaProperty, array: JsonArray): List = buildList { + if (array.size < property.minItems) { + add("property '$key': expected at least ${property.minItems} item(s), got ${array.size}") + } + property.items?.let { itemSchema -> + array.forEachIndexed { i, el -> + val itemTypeError = checkType(itemSchema.type, el) + when { + itemTypeError != null -> add("property '$key'[$i]: $itemTypeError") + itemSchema.type == "object" && itemSchema.properties.isNotEmpty() -> + validateProperty(itemSchema, el as JsonObject).forEach { add("property '$key'[$i]: $it") } } } } } + // Validate a nested object against a [JsonSchemaProperty] carrying its own shape — mirrors + // [validateObject] but keyed off the property's fields (they diverge only in defaults). + private fun validateProperty(property: JsonSchemaProperty, value: JsonObject): List { + val errors = mutableListOf() + property.required.filterNot { it in value }.forEach { errors += "missing required property '$it'" } + for ((key, element) in value) { + val nested = property.properties[key] + if (nested == null) { + if (!property.additionalProperties && property.properties.isNotEmpty()) { + errors += "unknown property '$key'" + } + } else { + errors += checkProperty(key, nested, element) + } + } + return errors + } + private fun checkType(type: String, value: JsonElement): String? { val ok = when (type) { "string" -> value is JsonPrimitive && value.isString diff --git a/docs/schemas/discovery.json b/docs/schemas/discovery.json new file mode 100644 index 00000000..860847bc --- /dev/null +++ b/docs/schemas/discovery.json @@ -0,0 +1,38 @@ +{ + "type": "object", + "properties": { + "ready": { + "type": "boolean", + "description": "true when the request is clear and grounded enough to plan; false when you are raising questions" + }, + "questions": { + "type": "array", + "description": "open questions only the operator can resolve. Empty when ready is true.", + "items": { + "type": "object", + "properties": { + "prompt": { + "type": "string", + "description": "the question, in full" + }, + "options": { + "type": "array", + "items": { "type": "string" }, + "description": "suggested answers when the answer is a choice among known alternatives" + }, + "multiSelect": { + "type": "boolean", + "description": "true if more than one option may apply (default false)" + }, + "header": { + "type": "string", + "description": "1-2 word label for the question (e.g. \"Scope\", \"Stack\", \"Endpoint\")" + } + }, + "required": ["prompt"] + } + } + }, + "required": ["ready", "questions"], + "additionalProperties": false +} diff --git a/examples/workflows/artifacts.config.toml b/examples/workflows/artifacts.config.toml index 880a5dbf..c2dcbfd7 100644 --- a/examples/workflows/artifacts.config.toml +++ b/examples/workflows/artifacts.config.toml @@ -9,6 +9,11 @@ # All four are llm_emitted: the stage's model emits JSON, which is validated against the schema # (never trusted) before the artifact counts as produced (invariant #7). +[[artifacts]] +id = "discovery" +schema_path = "schemas/discovery.json" +llm_emitted = true + [[artifacts]] id = "analysis" schema_path = "schemas/analysis.json" diff --git a/examples/workflows/freestyle_planning.toml b/examples/workflows/freestyle_planning.toml index 8cc83709..5dd3fa43 100644 --- a/examples/workflows/freestyle_planning.toml +++ b/examples/workflows/freestyle_planning.toml @@ -1,5 +1,16 @@ id = "freestyle_planning" -start = "analyst" +start = "discovery" + +# discovery runs before the analyst: it vets the request against the real repo and either clears +# it to plan or parks on a batched list of operator questions (grounds contradictions too, e.g. a +# request naming an endpoint the server doesn't expose). Read-only; owns clarification exclusively. +[[stages]] +id = "discovery" +prompt = "prompts/discovery.md" +produces = [{ name = "discovery", kind = "discovery" }] +allowed_tools = ["file_read", "list_dir", "shell"] +token_budget = 16384 +max_retries = 2 # analyst writes no files, but it owns task framing: task_search/task_context (read-only) find # existing work; task_create (T2, approval-gated — a task is an event-log entry, not a file write) @@ -24,6 +35,13 @@ produces = [{ name = "execution_plan", kind = "execution_plan" }] token_budget = 16384 max_retries = 2 +[[transitions]] +id = "discovery-to-analyst" +from = "discovery" +to = "analyst" +condition_type = "artifact_validated" +condition_artifact_id = "discovery" + [[transitions]] id = "analyst-to-architect" from = "analyst" diff --git a/examples/workflows/prompts/analyst.md b/examples/workflows/prompts/analyst.md index 7fb48c14..0bc7234d 100644 --- a/examples/workflows/prompts/analyst.md +++ b/examples/workflows/prompts/analyst.md @@ -22,11 +22,8 @@ Emit your result as the `analysis` artifact (JSON, schema provided): - `requirements`: concrete requirements / acceptance criteria, one per line. - `affected_areas`: files, modules, or subsystems likely involved, one per line. -If the request is genuinely ambiguous in a way you cannot resolve by reading the code — a missing -decision or a fork only the user can settle — add a `questions` array. Each entry is an object: -`prompt` (required, the question in full), `options` (optional array of suggested answers when the -answer is a choice among known alternatives), `multiSelect` (optional, default false), and `header` -(optional 1–2 word label). Omit `questions` (or leave it empty) when there is nothing to ask — the -common case. The user answers in a form and their answers return to you for a re-run. +Always produce the analysis — it is your single exit. Resolving operator forks and open questions +is the **Discovery** stage's responsibility, upstream of you; treat the request as settled and do +not ask the user anything here. Anything you cannot answer, answer by reading the code. Keep it factual and grounded in what you actually read. Do not propose a solution yet. diff --git a/examples/workflows/prompts/analyst_freestyle.md b/examples/workflows/prompts/analyst_freestyle.md index 3595e83b..603eb5df 100644 --- a/examples/workflows/prompts/analyst_freestyle.md +++ b/examples/workflows/prompts/analyst_freestyle.md @@ -26,32 +26,8 @@ Produce the `analysis` artifact by calling the **`emit_artifact`** tool with the Call `emit_artifact` once you have read enough — do not write the JSON as a plain message. -If — and only if — something genuinely blocks a plan (an ambiguous goal, a missing decision, a -fork only the user can resolve), add a `questions` array. Each entry is an object: -- `prompt` (required): the question, in full. -- `options` (optional): an array of suggested answers as strings. Offer these whenever the - answer is a choice among known alternatives. -- `multiSelect` (optional, default false): true if more than one option may apply. -- `header` (optional): a 1–2 word label for the question (e.g. "Scope", "Stack"). - -Greenfield or new-surface work (a new UI, app, or module) almost always hides such a fork even -when the high-level goal is clear: the build tool, styling approach, state/routing libraries, or -component library are choices only the user can pin, and a placeholder instruction does not -resolve them. Ask about stack/tooling in that case rather than guessing. - -Example: -```json -{ - "summary": "...", - "requirements": ["..."], - "affected_areas": ["..."], - "questions": [ - {"prompt": "Which frontend stack should the UI target?", - "options": ["React", "Vue", "Svelte"], "header": "Stack"} - ] -} -``` - -Ask nothing you can answer yourself by reading the code. Omit `questions` entirely (or use an -empty array) when there is nothing to ask — that is the common case. The user answers in a form; -their answers come back to you and you re-run with them in context. Do not design or plan yet. +Always produce the analysis — this is your single exit. Open questions and operator forks are the +**Discovery** stage's job, and it has already run before you: any ambiguity or contradiction the +user needed to resolve was raised and answered upstream, and those answers are in the decision +history above. Treat the request as settled, ground your requirements in what you actually read, +and do not ask the user anything. Do not design or plan yet. diff --git a/examples/workflows/prompts/architect_freestyle.md b/examples/workflows/prompts/architect_freestyle.md index 3d9acafa..9d8021f1 100644 --- a/examples/workflows/prompts/architect_freestyle.md +++ b/examples/workflows/prompts/architect_freestyle.md @@ -79,6 +79,12 @@ Emit a JSON object that validates against the `execution_plan` schema: 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. +- **Scaffolding uses `file_write`, never a network installer.** A stage that sets up a project + writes `package.json`, config files, and sources directly with `file_write` — it does not + shell out to `npm create vite`, `create-react-app`, `npx`, or any `create`/`init` scaffolder. + Those fetch remote code and prompt on stdin; the run is unattended (no TTY) so they hang, and + `shell` rejects them outright. `shell` in a scaffold stage is for `npm install` / `npm run + build` on files that already exist, not for generating the project. **edges** — describe every transition between stages. Rules: - `from` and `to` must each be a declared stage `id` or the literal string `"done"`. diff --git a/examples/workflows/prompts/discovery.md b/examples/workflows/prompts/discovery.md new file mode 100644 index 00000000..afaa041b --- /dev/null +++ b/examples/workflows/prompts/discovery.md @@ -0,0 +1,56 @@ +You are **Discovery** — the first role in a build pipeline, running *before* the analyst. Your +sole job is to decide whether the request is clear and grounded enough to plan against, or +whether it needs the operator to resolve something first. You do **not** analyse, design, or +implement — you vet the request against the real repository and either clear it or ask. + +Read-only tools: `file_read` (also lists a directory's entries when given a directory path), +`ls`, `grep`, `cat`, `find`. Use them — do not ask about things you can settle by reading the +code. + +Two checks, both grounded in what you actually read: + +1. **Underspecification.** Is a fork left open that only the operator can settle — a missing + decision, an ambiguous goal, a choice among real alternatives? Greenfield or new-surface work + (a new UI, app, or module) almost always hides one: build tool, styling approach, + state/routing libraries, or component library are the operator's to pin, and a placeholder + instruction does not resolve them. + +2. **Contradiction with the repo.** Verify concrete claims in the request against the actual + tree. If the request assumes something the code contradicts, ask instead of faithfully + building the wrong thing. Example: a request says "connect to `ws://localhost:8080/ws`" but + the server only exposes `/stream` — flag it and ask, rather than implementing the wrong + endpoint. + +Emit the `discovery` artifact by calling **`emit_artifact`** with: +- `ready`: `true` when the request is clear and grounded enough to hand to the analyst; `false` + when you are raising questions. +- `questions`: the open questions (empty when `ready` is true). Batch **all** of them into this + one list — do not ask one at a time. Each entry is an object: + - `prompt` (required): the question, in full. + - `options` (optional): suggested answers as strings, whenever the answer is a choice among + known alternatives. + - `multiSelect` (optional, default false): true if more than one option may apply. + - `header` (optional): a 1–2 word label (e.g. "Scope", "Stack", "Endpoint"). + +**Default to proceeding.** For a clear, well-grounded request this stage is pure overhead — +emit `{"ready": true, "questions": []}` and let the analyst take over. Only ask when a genuine +fork or contradiction blocks planning. Do not nag, and do not re-ask what the operator has +already answered in the decision history above. + +Example (needs input): +```json +{ + "ready": false, + "questions": [ + {"prompt": "Which frontend stack should the UI target?", + "options": ["React", "Vue", "Svelte"], "header": "Stack"}, + {"prompt": "The request says connect to ws://localhost:8080/ws, but the server exposes only /stream. Which is correct?", + "options": ["/stream (current server)", "/ws (add it to the server)"], "header": "Endpoint"} + ] +} +``` + +Example (clear — the common case): +```json +{ "ready": true, "questions": [] } +``` diff --git a/examples/workflows/role_pipeline.toml b/examples/workflows/role_pipeline.toml index cfdab97a..bcc74503 100644 --- a/examples/workflows/role_pipeline.toml +++ b/examples/workflows/role_pipeline.toml @@ -18,6 +18,8 @@ # # Requires these artifact kinds in ~/.config/correx/config.toml (schemas under docs/schemas/): # [[artifacts]] +# id = "discovery"; schema_path = "schemas/discovery.json"; llm_emitted = true +# [[artifacts]] # id = "analysis"; schema_path = "schemas/analysis.json"; llm_emitted = true # [[artifacts]] # id = "design"; schema_path = "schemas/design.json"; llm_emitted = true @@ -27,7 +29,18 @@ # Prompt files (prompts/*.md, relative to this workflow) must exist for a real run. id = "role_pipeline" -start = "analyst" +start = "discovery" + +# 0. Vet the request against the real repo before any analysis: park on a batched list of +# operator questions (underspecification or a claim the code contradicts) or clear it to +# proceed. Read-only; owns clarification exclusively so the analyst always emits an analysis. +[[stages]] +id = "discovery" +prompt = "prompts/discovery.md" +produces = [{ name = "discovery", kind = "discovery" }] +allowed_tools = ["file_read", "list_dir", "shell"] +token_budget = 16384 +max_retries = 2 # 1. Understand the request and the relevant code. Read-only. # ground_references: every workspace-relative file path the analysis names is checked @@ -93,6 +106,13 @@ max_retries = 2 # --- forward edges --- +[[transitions]] +id = "discovery-to-analyst" +from = "discovery" +to = "analyst" +condition_type = "artifact_validated" +condition_artifact_id = "discovery" + [[transitions]] id = "analyst-to-architect" from = "analyst" diff --git a/infrastructure/inference/openai_compat/src/main/kotlin/com/correx/infrastructure/inference/openai/OpenAiCompatInferenceProvider.kt b/infrastructure/inference/openai_compat/src/main/kotlin/com/correx/infrastructure/inference/openai/OpenAiCompatInferenceProvider.kt index 07d311f7..4d68202a 100644 --- a/infrastructure/inference/openai_compat/src/main/kotlin/com/correx/infrastructure/inference/openai/OpenAiCompatInferenceProvider.kt +++ b/infrastructure/inference/openai_compat/src/main/kotlin/com/correx/infrastructure/inference/openai/OpenAiCompatInferenceProvider.kt @@ -145,6 +145,7 @@ class OpenAiCompatInferenceProvider( ), latencyMs = System.currentTimeMillis() - startTime, toolCalls = toolCalls, + reasoning = message.reasoningContent?.takeIf { it.isNotBlank() }, ) } diff --git a/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileWriteTool.kt b/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileWriteTool.kt index b6068e32..a9a7a9f5 100644 --- a/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileWriteTool.kt +++ b/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileWriteTool.kt @@ -39,7 +39,9 @@ class FileWriteTool( private val normalizedAllowedPaths: Set = allowedPaths.map { it.normalize().toAbsolutePath() }.toSet() override val name: String = "file_write" - override val description: String = "Write content to a file at the specified relative path (creates or overwrites)" + override val description: String = "Write content to a file at the specified relative path (creates or overwrites). " + + "Writing to a nested path auto-creates any missing parent directories — there is no separate " + + "mkdir step. Do not write shell commands into a file's content." override val parametersSchema: JsonObject = buildJsonObject { put("type", "object") putJsonObject("properties") { diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/shell/ShellTool.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/shell/ShellTool.kt index 8a3444c4..192ae6d9 100644 --- a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/shell/ShellTool.kt +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/shell/ShellTool.kt @@ -70,12 +70,44 @@ class ShellTool( when (val parsed = parseArgv(request.parameters["argv"])) { is ArgvParse.Bad -> ValidationResult.Invalid(parsed.reason) is ArgvParse.Ok -> when { + // Baseline denylist runs FIRST, ahead of the allowlist — a hard floor that holds even + // when allowedExecutables is empty (allow-all). Blocks the class of command that + // fetches+executes remote code or prompts interactively: an unattended run has no TTY, + // so a create-* scaffolder / npx blocks forever (or balloons the machine). The T2 gate + // is not enough — an auto-approve loop waves these straight through. + deniedReason(parsed.argv) != null -> + ValidationResult.Invalid(deniedReason(parsed.argv)!!) allowedExecutables.isNotEmpty() && parsed.argv[0] !in allowedExecutables -> ValidationResult.Invalid("Executable '${parsed.argv[0]}' is not in the allowed list.") else -> ValidationResult.Valid } } + // Return a rejection reason if argv is a fetch-and-execute-remote-code or interactive-scaffolder + // invocation, else null. Matched by shape, not just argv[0], because the danger for package + // managers is the *subcommand* (`npm install` is fine; `npm create`/`npm init` scaffolds and + // prompts). Deterministic, allowlist-independent — see [validateRequest]. + private fun deniedReason(argv: List): String? { + // Scan every token, not just argv[0]: a scaffolder can hide inside a shell command line that + // runs via `sh -c` (`cd web && npm create vite`), where argv[0] is `cd`. + val progs = argv.map { it.substringAfterLast('/') } + val redirect = "Scaffold by writing files directly with file_write (package.json, configs, " + + "sources) — do not shell out to a network installer; it needs a TTY and will hang unattended." + val runner = progs.firstOrNull { it in REMOTE_EXEC_RUNNERS } + val scaffolder = progs.firstOrNull { it.startsWith("create-") } + val pmIdx = progs.indexOfFirst { it in PACKAGE_MANAGERS } + return when { + runner != null -> + "'$runner' fetches and runs remote code with no TTY, which hangs an unattended run. $redirect" + scaffolder != null -> + "'$scaffolder' is an interactive project scaffolder that prompts on stdin. $redirect" + pmIdx >= 0 && progs.getOrNull(pmIdx + 1) in SCAFFOLD_SUBCOMMANDS -> + "'${progs[pmIdx]} ${progs[pmIdx + 1]}' scaffolds a project interactively " + + "(prompts on stdin). $redirect" + else -> null + } + } + private sealed interface ArgvParse { data class Ok(val argv: List) : ArgvParse data class Bad(val reason: String) : ArgvParse @@ -179,6 +211,12 @@ class ShellTool( const val EMPTY_MSG = "Missing or empty 'argv' parameter. Expected a JSON array of strings." val SHELL_BUILTINS = setOf("cd", "export", "source", ".", "pushd", "popd", "umask", "ulimit") val SHELL_OPERATORS = setOf("&&", "||", "|", ">", ">>", "<", ";", "&", "2>", "2>&1") + // Runners that download and execute an arbitrary remote package in one shot. + val REMOTE_EXEC_RUNNERS = setOf("npx", "bunx", "pnpx") + val PACKAGE_MANAGERS = setOf("npm", "yarn", "pnpm", "bun") + // Package-manager subcommands that generate a project by pulling+running a remote scaffolder + // and prompting on stdin (npm create vite, yarn create next-app, pnpm dlx, …). + val SCAFFOLD_SUBCOMMANDS = setOf("create", "init", "dlx") } private suspend fun runCmd(request: ToolRequest, process: Process): ToolResult = withTimeout(timeoutMs) { diff --git a/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/shell/ShellToolTest.kt b/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/shell/ShellToolTest.kt index 05150022..1aa10f29 100644 --- a/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/shell/ShellToolTest.kt +++ b/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/shell/ShellToolTest.kt @@ -180,4 +180,53 @@ class ShellToolTest { fun `validateRequest accepts a clean string argv`() { assertEquals(ValidationResult.Valid, ShellTool().validateRequest(createRequest(listOf("mkdir", "-p", "dir")))) } + + @Test + fun `denylist blocks npm create even in allow-all mode`() { + // Repro: gemma ran `npm create vite@latest` — a network scaffolder that prompts on stdin. + // Empty allowedExecutables = allow-all, yet this must still be rejected. + val tool = ShellTool(allowedExecutables = emptySet()) + val result = tool.validateRequest(createRequest(listOf("npm", "create", "vite"))) + assertTrue(result is ValidationResult.Invalid) + val reason = (result as ValidationResult.Invalid).reason + assertTrue(reason.contains("npm create"), reason) + assertTrue(reason.contains("file_write"), reason) + } + + @Test + fun `denylist blocks npx remote runner`() { + val result = ShellTool().validateRequest(createRequest(listOf("npx", "create-react-app", "app"))) + assertTrue(result is ValidationResult.Invalid) + assertTrue((result as ValidationResult.Invalid).reason.contains("remote code"), result.reason) + } + + @Test + fun `denylist blocks create- scaffolder run directly`() { + val result = ShellTool().validateRequest(createRequest(listOf("create-next-app", "app"))) + assertTrue(result is ValidationResult.Invalid) + assertTrue((result as ValidationResult.Invalid).reason.contains("scaffolder"), result.reason) + } + + @Test + fun `denylist catches scaffolder hidden in a shell command line`() { + // argv[0] is `cd`, but `npm create` rides along and would run via sh -c. + val result = ShellTool().validateRequest(createRequest(listOf("cd", "web", "&&", "npm", "create", "vite"))) + assertTrue(result is ValidationResult.Invalid) + assertTrue((result as ValidationResult.Invalid).reason.contains("npm create"), result.reason) + } + + @Test + fun `denylist allows npm install`() { + // The subcommand is what's dangerous — `npm install` on existing files is fine. + assertEquals(ValidationResult.Valid, ShellTool().validateRequest(createRequest(listOf("npm", "install")))) + } + + @Test + fun `denylist takes precedence over the allowlist`() { + // Even if npm is explicitly allowed, `npm create` stays blocked. + val tool = ShellTool(allowedExecutables = setOf("npm")) + val result = tool.validateRequest(createRequest(listOf("npm", "create", "vite"))) + assertTrue(result is ValidationResult.Invalid) + assertTrue((result as ValidationResult.Invalid).reason.contains("file_write"), result.reason) + } } diff --git a/infrastructure/workflow/build.gradle b/infrastructure/workflow/build.gradle index 7b27ea38..40351109 100644 --- a/infrastructure/workflow/build.gradle +++ b/infrastructure/workflow/build.gradle @@ -8,6 +8,7 @@ dependencies { implementation(project(":core:inference")) implementation(project(":core:events")) implementation(project(":core:artifacts")) + implementation(project(":core:tools")) implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-toml:2.17.0") implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.17.0") testImplementation "org.junit.jupiter:junit-jupiter" diff --git a/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/CapabilityGapDetector.kt b/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/CapabilityGapDetector.kt new file mode 100644 index 00000000..cdb3cbd3 --- /dev/null +++ b/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/CapabilityGapDetector.kt @@ -0,0 +1,70 @@ +package com.correx.infrastructure.workflow + +import com.correx.core.tools.contract.ToolCapability +import com.correx.core.transitions.graph.StageConfig +import com.correx.core.transitions.graph.WorkflowGraph + +/** + * A stage whose declared intent implies a tool capability it was not granted (Vikunja #30 part 1). + * Advisory only — recorded so a future reflection rung (part 2, LLM "are you sure?") can consume it; + * it never blocks the plan-compile gate and never grants a tool itself (invariants #3 LLM-proposes/ + * core-decides, #4 policy-absolute, #5 tiers-declared-not-inferred). + */ +data class CapabilityGap( + val stageId: String, + val impliedCapability: ToolCapability, + val phrase: String, + val grantedCapabilities: Set, +) + +/** + * Deterministic, pure-Kotlin detector over a compiled plan graph — the plan-compile-gate counterpart + * to [PlanLinter]'s structural checks. Zero inference, zero I/O: a function of the graph and a fixed + * verb map, so it is replay-safe by construction (invariant #8). + * + * The verb→capability map below is intentionally small and conservative: each entry is a single + * word/phrase that, in an imperative stage brief ("write the file", "run the build"), reliably + * signals a concrete tool action. Words that are ambiguous or too common in ordinary planning + * prose (e.g. "make", "handle", "process", "check") are deliberately excluded — a false positive + * here is just a discarded advisory finding, but a map that fires on every brief would drown the + * signal the future reflection rung needs. + */ +object CapabilityGapDetector { + + private val impliedCapabilities: Map> = mapOf( + ToolCapability.FILE_WRITE to listOf("write", "create", "generate", "scaffold", "save"), + ToolCapability.FILE_READ to listOf("read", "inspect"), + ToolCapability.SHELL_EXEC to listOf("run", "build", "execute", "compile"), + ToolCapability.NETWORK_ACCESS to listOf("search the web", "fetch a url", "download"), + ) + + /** + * [toolCapabilities] resolves each stage's `allowedTools` name to the capabilities that tool + * grants (a tool's [com.correx.core.tools.contract.Tool.requiredCapabilities]) — the caller + * supplies it (typically `ToolRegistry.all().associate { it.name to it.requiredCapabilities }`) + * so this detector stays a pure function of plain data, not a live registry. + */ + fun detect(graph: WorkflowGraph, toolCapabilities: Map>): List = + graph.stages.entries.flatMap { (id, stage) -> + val granted = grantedCapabilities(stage, toolCapabilities) + val brief = briefOf(stage).lowercase() + impliedCapabilities.entries.flatMap { (capability, phrases) -> + if (capability in granted) { + emptyList() + } else { + phrases.filter { phrase -> containsPhrase(brief, phrase) } + .map { phrase -> CapabilityGap(id.value, capability, phrase, granted) } + } + } + } + + private fun grantedCapabilities( + stage: StageConfig, + toolCapabilities: Map>, + ): Set = stage.allowedTools.flatMap { toolCapabilities[it].orEmpty() }.toSet() + + private fun briefOf(stage: StageConfig): String = stage.metadata["promptInline"] ?: "" + + private fun containsPhrase(text: String, phrase: String): Boolean = + Regex("\\b${Regex.escape(phrase)}\\b").containsMatchIn(text) +} diff --git a/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/CapabilityGapDetectorTest.kt b/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/CapabilityGapDetectorTest.kt new file mode 100644 index 00000000..addefc94 --- /dev/null +++ b/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/CapabilityGapDetectorTest.kt @@ -0,0 +1,101 @@ +package com.correx.infrastructure.workflow + +import com.correx.core.artifacts.kind.ConfigArtifactKind +import com.correx.core.artifacts.kind.JsonSchema +import com.correx.core.artifacts.kind.TypedArtifactSlot +import com.correx.core.events.types.ArtifactId +import com.correx.core.events.types.StageId +import com.correx.core.events.types.TransitionId +import com.correx.core.tools.contract.ToolCapability +import com.correx.core.transitions.conditions.AlwaysTrue +import com.correx.core.transitions.graph.StageConfig +import com.correx.core.transitions.graph.TransitionEdge +import com.correx.core.transitions.graph.WorkflowGraph +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class CapabilityGapDetectorTest { + + private fun kind(id: String) = + ConfigArtifactKind(id, JsonSchema(type = "object", properties = emptyMap(), additionalProperties = true)) + + private fun stage( + prompt: String, + tools: Set = emptySet(), + produces: List = emptyList(), + ) = StageConfig( + produces = produces.map { TypedArtifactSlot(ArtifactId(it), kind(it)) }, + allowedTools = tools, + metadata = mapOf("promptInline" to prompt), + ) + + private fun graph(stages: Map, start: String = stages.keys.first()) = WorkflowGraph( + id = "test", + stages = stages.mapKeys { StageId(it.key) }, + transitions = setOf(TransitionEdge(TransitionId("e0"), StageId(start), StageId("done"), AlwaysTrue)), + start = StageId(start), + ) + + private val fileWriteTool = "file_write" to setOf(ToolCapability.FILE_WRITE) + private val fileReadTool = "file_read" to setOf(ToolCapability.FILE_READ) + private val shellTool = "shell" to setOf(ToolCapability.SHELL_EXEC) + + @Test + fun `a stage that writes a file without FILE_WRITE is a gap`() { + val g = graph(mapOf("impl" to stage(prompt = "Create the new config file", tools = emptySet()))) + val gaps = CapabilityGapDetector.detect(g, emptyMap()) + val gap = gaps.single() + assertEquals("impl", gap.stageId) + assertEquals(ToolCapability.FILE_WRITE, gap.impliedCapability) + assertEquals("create", gap.phrase) + assertTrue(gap.grantedCapabilities.isEmpty()) + } + + @Test + fun `a stage granted FILE_WRITE has no gap for a write-implying brief`() { + val g = graph(mapOf("impl" to stage(prompt = "Create the new config file", tools = setOf(fileWriteTool.first)))) + val gaps = CapabilityGapDetector.detect(g, mapOf(fileWriteTool)) + assertTrue(gaps.isEmpty(), "expected no gap, got $gaps") + } + + @Test + fun `the motivating case - a recovery style stage with read-only tools implies write`() { + val prompt = "Read the gate output and edit the files to fix the reported cause, then save your work." + val g = graph(mapOf("recovery" to stage(prompt = prompt, tools = setOf(fileReadTool.first)))) + val gaps = CapabilityGapDetector.detect(g, mapOf(fileReadTool)) + val writeGap = gaps.single { it.impliedCapability == ToolCapability.FILE_WRITE } + assertEquals("save", writeGap.phrase) + assertTrue(gaps.none { it.impliedCapability == ToolCapability.FILE_READ }, "read is granted, no gap expected") + } + + @Test + fun `run a build without SHELL_EXEC is a gap`() { + val g = graph(mapOf("verify" to stage(prompt = "Run the build and report the result"))) + val gaps = CapabilityGapDetector.detect(g, emptyMap()) + assertTrue(gaps.any { it.impliedCapability == ToolCapability.SHELL_EXEC && it.phrase == "run" }) + assertTrue(gaps.any { it.impliedCapability == ToolCapability.SHELL_EXEC && it.phrase == "build" }) + } + + @Test + fun `a stage with an unrelated brief and no tools has no gaps`() { + val g = graph(mapOf("analyse" to stage(prompt = "Summarize the requirements at a high level"))) + val gaps = CapabilityGapDetector.detect(g, emptyMap()) + assertTrue(gaps.isEmpty(), "expected no gap, got $gaps") + } + + @Test + fun `a stage fully equipped for its brief has no gaps`() { + val prompt = "Read the spec, run the build, and create the output file" + val g = graph( + mapOf( + "full" to stage( + prompt = prompt, + tools = setOf(fileReadTool.first, shellTool.first, fileWriteTool.first), + ), + ), + ) + val gaps = CapabilityGapDetector.detect(g, mapOf(fileReadTool, shellTool, fileWriteTool)) + assertTrue(gaps.isEmpty(), "expected no gap, got $gaps") + } +} 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 1e09ae49..142f7c67 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 @@ -26,6 +26,7 @@ class FreestylePlanningWorkflowTest { ) private val registry = DefaultArtifactKindRegistry().apply { + register(llmKind("discovery")) register(llmKind("analysis")) register(llmKind("execution_plan")) } @@ -41,17 +42,22 @@ class FreestylePlanningWorkflowTest { } @Test - fun `freestyle_planning toml parses with two non-terminal stages and both edges`() { + fun `freestyle_planning toml parses with discovery start plus analyst and architect stages`() { val path = repoFile("examples/workflows/freestyle_planning.toml") assumeTrue(path != null, "freestyle_planning.toml not found from ${System.getProperty("user.dir")}") val graph = TomlWorkflowLoader(registry).load(path!!) + assertEquals(StageId("discovery"), graph.start) assertEquals( - setOf("analyst", "architect"), + setOf("discovery", "analyst", "architect"), graph.stages.keys.map { it.value }.toSet(), ) + val discoveryToAnalyst = graph.transitions.first { it.from == StageId("discovery") } + assertEquals(StageId("analyst"), discoveryToAnalyst.to) + assertTrue(discoveryToAnalyst.condition is ArtifactValidated) + val architect = graph.stages[StageId("architect")]!! assertTrue( architect.produces.any { it.name.value == "execution_plan" && it.kind.llmEmitted }, @@ -68,7 +74,7 @@ class FreestylePlanningWorkflowTest { assertEquals(StageId("done"), architectToDone.to) assertTrue(architectToDone.condition is ArtifactValidated) - assertEquals(2, graph.transitions.size) + assertEquals(3, graph.transitions.size) // analyst frames the work: search + open one task or decompose into a graph (the architect // threads the named task into the plan). 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 fbba416a..684f4744 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 @@ -28,6 +28,7 @@ class RolePipelineWorkflowTest { ) private val registry = DefaultArtifactKindRegistry().apply { + register(llmKind("discovery")) register(llmKind("analysis")) register(llmKind("design")) register(llmKind("review_report")) @@ -54,11 +55,16 @@ class RolePipelineWorkflowTest { assumeTrue(graph != null, "role_pipeline.toml not found from ${System.getProperty("user.dir")}") graph!! + assertEquals(StageId("discovery"), graph.start) assertEquals( - setOf("analyst", "architect", "decomposer", "implementer", "reviewer"), + setOf("discovery", "analyst", "architect", "decomposer", "implementer", "reviewer"), graph.stages.keys.map { it.value }.toSet(), ) - assertEquals(7, graph.transitions.size) + assertEquals(8, graph.transitions.size) + + // discovery is the read-only clarification gate that precedes the analyst. + val discoveryToAnalyst = graph.transitions.first { it.from == StageId("discovery") } + assertEquals(StageId("analyst"), discoveryToAnalyst.to) // The decomposer is the mandatory task-graph gate. assertEquals("true", graph.stages[StageId("decomposer")]!!.metadata["requireTaskDecompose"])