feat(guardrails): steering channel + shell-in-file rule + capability-gap detector

Bundles three operator-reliability guardrails (Vikunja #28/#29/#30) plus the
in-flight branch WIP they were built on top of (reasoning_content capture,
operator/project profile editor, write-jail workspaceRoot fix) — the tree is
interdependent (SessionOrchestrator references reasoningArtifactId from the WIP)
and does not compile as separable subsets, so it lands as one commit.

Guardrails:
- #28 mid-stage steering: ClientMessage.SteerSession -> GlobalStreamHandler ->
  orchestrator.submitSteering, reusing SteeringNoteAddedEvent + existing context
  fold (advisory, non-authoritative; invariants #3/#7). Closes the gap where
  steering typed off an approval gate was silently dropped.
- #29 shell-in-file guardrail: ShellInFileContentRule (core:toolintent) blocks a
  file_write whose content is a bare shell command (e.g. "mkdir -p ..."); FileWriteTool
  description now advertises auto-mkdir of parent dirs. Basename-allowlist so the
  extensionless case is caught; scripts/Makefiles/multiline exempt.
- #30 pt1 capability-gap detector: deterministic CapabilityGapDetector maps stage
  intent -> implied ToolCapability, compares to granted tools, emits advisory
  CapabilityGapDetectedEvent in FreestyleDriver.lockAndRun. Recorded, never fails
  the gate and never auto-grants (invariants #3/#4/#5). Reflection rung is pt2.

Verified: ./gradlew check green (whole tree).
This commit is contained in:
2026-07-07 13:27:59 +04:00
parent 879672a47d
commit 41ed6414c6
43 changed files with 1592 additions and 94 deletions
@@ -68,6 +68,7 @@ import com.correx.core.toolintent.rules.NetworkHostRule
import com.correx.core.toolintent.rules.PathContainmentRule import com.correx.core.toolintent.rules.PathContainmentRule
import com.correx.core.toolintent.rules.ReadBeforeWriteRule import com.correx.core.toolintent.rules.ReadBeforeWriteRule
import com.correx.core.toolintent.rules.ReferenceExistsRule 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.StaleWriteRule
import com.correx.core.toolintent.rules.WriteScopeRule import com.correx.core.toolintent.rules.WriteScopeRule
import com.correx.apps.server.freestyle.FreestyleDriver import com.correx.apps.server.freestyle.FreestyleDriver
@@ -200,15 +201,20 @@ fun main() {
?.let { Path.of(it) } ?.let { Path.of(it) }
?: toolsConfig.sandboxRoot.takeIf { it.isNotEmpty() }?.let { Path.of(it) } ?: toolsConfig.sandboxRoot.takeIf { it.isNotEmpty() }?.let { Path.of(it) }
?: Paths.get(System.getProperty("user.home"), ".config", "correx", "sandbox") ?: 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) } ?.let { Path.of(it) }
?: toolsConfig.workingDir.takeIf { it.isNotEmpty() }?.let { Path.of(it) } ?: toolsConfig.workingDir.takeIf { it.isNotEmpty() }?.let { Path.of(it) }
?: Path.of("").toAbsolutePath() val explicitWorkspaceRoot = System.getenv("CORREX_WORKSPACE_ROOT")
val shellAllowedExecutables = toolsConfig.shellAllowedExecutables.toSet()
val workspaceRoot = System.getenv("CORREX_WORKSPACE_ROOT")
?.let { Path.of(it) } ?.let { Path.of(it) }
?: toolsConfig.workspaceRoot.takeIf { it.isNotEmpty() }?.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 // 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. // (web_search/web_fetch). Built only when research is enabled, so the static path stays offline.
val researchToolConfig = com.correx.infrastructure.tools.ResearchToolConfig( val researchToolConfig = com.correx.infrastructure.tools.ResearchToolConfig(
@@ -274,6 +280,7 @@ fun main() {
PathContainmentRule(), PathContainmentRule(),
ReadBeforeWriteRule(), ReadBeforeWriteRule(),
ReferenceExistsRule(), ReferenceExistsRule(),
ShellInFileContentRule(),
WriteScopeRule(), WriteScopeRule(),
StaleWriteRule(), StaleWriteRule(),
ManifestContainmentRule(), ManifestContainmentRule(),
@@ -510,6 +517,7 @@ fun main() {
config = defaultOrchestrationConfig, config = defaultOrchestrationConfig,
runPhase2 = { sid, graph, cfg -> orchestrator.run(sid, graph, cfg) }, runPhase2 = { sid, graph, cfg -> orchestrator.run(sid, graph, cfg) },
requestPlanApproval = { sid, planJson -> orchestrator.requestPlanApproval(sid, planJson) }, 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 // 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. // recorded system-session events so a restart doesn't re-emit a degraded already in the log.
@@ -340,6 +340,7 @@ private suspend fun mapInferenceCompleted(
outputSummary = this, outputSummary = this,
responseText = this, responseText = this,
occurredAt = event.metadata.timestamp.toEpochMilliseconds(), occurredAt = event.metadata.timestamp.toEpochMilliseconds(),
totalTokens = p.tokensUsed.totalTokens,
sequence = event.sequence, sequence = event.sequence,
sessionSequence = sessionSequence, sessionSequence = sessionSequence,
) )
@@ -181,7 +181,7 @@ class SessionEventBridge(
ToolDecl(name = tool.name, tier = tool.tier.level) 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( send(ServerMessage.StageToolManifest(
sessionId = sessionId, sessionId = sessionId,
@@ -1,16 +1,20 @@
package com.correx.apps.server.freestyle 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.EventMetadata
import com.correx.core.events.events.ExecutionPlanLockedEvent import com.correx.core.events.events.ExecutionPlanLockedEvent
import com.correx.core.events.events.ExecutionPlanRejectedEvent import com.correx.core.events.events.ExecutionPlanRejectedEvent
import com.correx.core.events.events.NewEvent import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.PlanLintCompletedEvent 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.PlanLintResult
import com.correx.infrastructure.workflow.PlanLinter import com.correx.infrastructure.workflow.PlanLinter
import com.correx.core.events.stores.EventStore import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.ArtifactId import com.correx.core.events.types.ArtifactId
import com.correx.core.events.types.EventId import com.correx.core.events.types.EventId
import com.correx.core.events.types.SessionId 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.execution.WorkflowResult
import com.correx.core.kernel.orchestration.OrchestrationConfig import com.correx.core.kernel.orchestration.OrchestrationConfig
import com.correx.core.transitions.graph.WorkflowGraph 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 * [runPhase2] is a functional seam so the driver can be tested without the full
* [DefaultSessionOrchestrator] constructor chain. * [DefaultSessionOrchestrator] constructor chain.
*/ */
@Suppress("LongParameterList")
class FreestyleDriver( class FreestyleDriver(
private val eventStore: EventStore, private val eventStore: EventStore,
private val compiler: ExecutionPlanCompiler, private val compiler: ExecutionPlanCompiler,
@@ -33,6 +38,10 @@ class FreestyleDriver(
private val config: OrchestrationConfig, private val config: OrchestrationConfig,
private val runPhase2: suspend (SessionId, WorkflowGraph, OrchestrationConfig) -> WorkflowResult, private val runPhase2: suspend (SessionId, WorkflowGraph, OrchestrationConfig) -> WorkflowResult,
private val requestPlanApproval: suspend (SessionId, String) -> Boolean = { _, _ -> true }, 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<String, Set<ToolCapability>> = emptyMap(),
) { ) {
suspend fun lockAndRun(sessionId: SessionId) { suspend fun lockAndRun(sessionId: SessionId) {
val json = planContent(sessionId) ?: run { val json = planContent(sessionId) ?: run {
@@ -59,6 +68,16 @@ class FreestyleDriver(
emitRejected(sessionId, "plan failed lint: $summary", "lint") emitRejected(sessionId, "plan failed lint: $summary", "lint")
return 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) val approved = requestPlanApproval(sessionId, json)
if (!approved) { if (!approved) {
log.info("freestyle: execution plan rejected by operator for session={}", sessionId.value) 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) { private suspend fun emitRejected(sessionId: SessionId, reason: String, source: String) {
eventStore.append( eventStore.append(
NewEvent( NewEvent(
@@ -86,6 +86,32 @@ sealed class ClientMessage {
@Serializable @Serializable
data class UpdateConfig(val patch: Map<String, String>) : ClientMessage() data class UpdateConfig(val patch: Map<String, String>) : 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<String>,
val commands: Map<String, String>,
) : 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<String>,
val conventions: List<String>,
) : ClientMessage()
@Serializable @Serializable
data class ApprovalResponse( data class ApprovalResponse(
val requestId: ApprovalRequestId, val requestId: ApprovalRequestId,
@@ -102,6 +128,18 @@ sealed class ClientMessage {
val answers: List<ClarificationAnswer>, val answers: List<ClarificationAnswer>,
) : ClientMessage() ) : 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 @Serializable
data class CreateGrant( data class CreateGrant(
val sessionId: SessionId, val sessionId: SessionId,
@@ -57,6 +57,7 @@ enum class PauseReason {
data class StageToolDecl( data class StageToolDecl(
val stageId: String, val stageId: String,
val tools: List<ToolDecl>, val tools: List<ToolDecl>,
val tokenBudget: Int? = null,
) )
@Serializable @Serializable
@@ -189,6 +189,7 @@ sealed interface ServerMessage {
val outputSummary: String, val outputSummary: String,
val responseText: String = "", val responseText: String = "",
val occurredAt: Long, val occurredAt: Long,
val totalTokens: Int? = null,
override val sequence: Long, override val sequence: Long,
override val sessionSequence: Long, override val sessionSequence: Long,
) : ServerMessage, SessionMessage ) : ServerMessage, SessionMessage
@@ -546,6 +547,33 @@ sealed interface ServerMessage {
override val sessionSequence: Long? = null, override val sessionSequence: Long? = null,
) : ServerMessage, NonEventMessage ) : 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<String>,
val commands: Map<String, String>,
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<String>,
val conventions: List<String>,
val proposedAdaptation: String? = null,
val error: String? = null,
override val sequence: Long? = null,
override val sessionSequence: Long? = null,
) : ServerMessage, NonEventMessage
// -- Router -- // -- Router --
@Serializable @Serializable
@@ -32,6 +32,11 @@ import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId import com.correx.core.events.types.StageId
import com.correx.core.inference.ProviderHealth import com.correx.core.inference.ProviderHealth
import com.correx.core.kernel.orchestration.WorkspaceContext 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.ProjectProfileLoader
import com.correx.core.config.ProjectProfileWriter import com.correx.core.config.ProjectProfileWriter
import com.correx.core.config.withConvention import com.correx.core.config.withConvention
@@ -56,6 +61,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.datetime.Clock import kotlinx.datetime.Clock
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import java.nio.file.Files
import java.util.UUID import java.util.UUID
private val log = LoggerFactory.getLogger(GlobalStreamHandler::class.java) 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.GetHealthChecks -> sendFrame(queries.healthChecks())
is ClientMessage.GetConfig -> sendFrame(queries.configSnapshot(error = null, restartRequired = emptyList())) is ClientMessage.GetConfig -> sendFrame(queries.configSnapshot(error = null, restartRequired = emptyList()))
is ClientMessage.UpdateConfig -> sendFrame(queries.updateConfig(msg.patch)) 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.ResumeSession -> session.send(encodeError("ResumeSession not supported"))
is ClientMessage.ApprovalResponse -> handleApprovalResponse(msg, sendFrame) is ClientMessage.ApprovalResponse -> handleApprovalResponse(msg, sendFrame)
is ClientMessage.ClarificationResponse -> handleClarificationResponse(msg) is ClientMessage.ClarificationResponse -> handleClarificationResponse(msg)
is ClientMessage.SteerSession -> handleSteerSession(msg)
is ClientMessage.CreateGrant -> handleCreateGrant(msg, sendFrame) is ClientMessage.CreateGrant -> handleCreateGrant(msg, sendFrame)
is ClientMessage.RevokeGrant -> handleRevokeGrant(msg, sendFrame) is ClientMessage.RevokeGrant -> handleRevokeGrant(msg, sendFrame)
is ClientMessage.ListGrants -> sendFrame(queries.listGrants()) 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 // 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. // events stay co-located), then reply with the refreshed board. A missing idea is a no-op.
private suspend fun handleDiscardIdea( private suspend fun handleDiscardIdea(
@@ -397,6 +418,131 @@ class GlobalStreamHandler(private val module: ServerModule) {
.lastOrNull() .lastOrNull()
?.workspaceRoot ?.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 errorResponse(message: String) = ServerMessage.ProtocolError(message)
private fun encodeError(message: String): Frame.Text = private fun encodeError(message: String): Frame.Text =
@@ -260,7 +260,8 @@ class DomainEventMapperTest {
val result = domainEventToServerMessage(event, store, sessionSequence = 0L) val result = domainEventToServerMessage(event, store, sessionSequence = 0L)
assertEquals( assertEquals(
ServerMessage.InferenceCompleted( ServerMessage.InferenceCompleted(
sessionId, stageId, responseText, responseText, occurredAt, event.sequence, 0L, sessionId, stageId, responseText, responseText, occurredAt,
totalTokens = 15, sequence = event.sequence, sessionSequence = 0L,
), ),
result, result,
) )
@@ -281,7 +282,10 @@ class DomainEventMapperTest {
) )
val result = domainEventToServerMessage(event, noopStore, sessionSequence = 0L) val result = domainEventToServerMessage(event, noopStore, sessionSequence = 0L)
assertEquals( assertEquals(
ServerMessage.InferenceCompleted(sessionId, stageId, "", "", occurredAt, event.sequence, 0L), ServerMessage.InferenceCompleted(
sessionId, stageId, "", "", occurredAt,
totalTokens = 15, sequence = event.sequence, sessionSequence = 0L,
),
result, result,
) )
} }
+62 -26
View File
@@ -64,6 +64,8 @@ const (
OverlayGrants OverlayGrants
OverlayGrantScope OverlayGrantScope
OverlayHelp OverlayHelp
OverlayProjectProfile
OverlayOperatorProfile
) )
// RouterEntry is one line in a session's conversation transcript. // RouterEntry is one line in a session's conversation transcript.
@@ -109,6 +111,13 @@ type ManifestTool struct {
Tier int 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. // Approval is a pending approval gate for a session.
type Approval struct { type Approval struct {
RequestID string RequestID string
@@ -179,18 +188,20 @@ type Proposal struct {
// Session is the UI's view of one server session. // Session is the UI's view of one server session.
type Session struct { type Session struct {
ID string ID string
Status string Status string
WorkflowID string WorkflowID string
Name string Name string
LastEventAt int64 LastEventAt int64
CurrentStage string CurrentStage string
WorkspaceRoot string // bound cwd, from session.workspace_bound WorkspaceRoot string // bound cwd, from session.workspace_bound
LastOutput string LastOutput string
LastResponse string LastResponse string
Tools []ToolRecord Tools []ToolRecord
ToolsByStage map[string][]ManifestTool ToolsByStage map[string][]ManifestTool
Events []EventEntry 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 // 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 // PendingQueue[PendingIdx] (kept in sync by syncPending) so the render code can
// keep reading a single pointer while multiple gates queue up behind it. // 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 configRestart []string // keys from the last save that need a restart
configLoading bool 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 // session stats (OverlayStats) — populated by the session.stats reply
stats *protocol.StatsDto stats *protocol.StatsDto
statsFor string // sessionId the current stats belong to statsFor string // sessionId the current stats belong to
@@ -413,20 +447,22 @@ type Model struct {
// NewModel builds the initial idle state. // NewModel builds the initial idle state.
func NewModel(client *ws.Client) Model { func NewModel(client *ws.Client) Model {
return Model{ return Model{
client: client, client: client,
theme: NewTheme(SoftBlue), theme: NewTheme(SoftBlue),
wfIndex: -1, wfIndex: -1,
inputMode: ModeRouter, inputMode: ModeRouter,
historyIndex: -1, historyIndex: -1,
routerMessages: map[string][]RouterEntry{}, routerMessages: map[string][]RouterEntry{},
configStaged: map[string]string{}, configStaged: map[string]string{},
chatMode: ChatModeChat, projectProfileStaged: map[string]string{},
providerType: "LOCAL", operatorProfileStaged: map[string]string{},
snapshotPhase: true, chatMode: ChatModeChat,
eventStripShown: true, providerType: "LOCAL",
transcriptSel: -1, snapshotPhase: true,
sbHidden: loadStatusbarHidden(), eventStripShown: true,
actionsHidden: loadPrefs().InlineActionsHidden, transcriptSel: -1,
sbHidden: loadStatusbarHidden(),
actionsHidden: loadPrefs().InlineActionsHidden,
} }
} }
@@ -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
}
+20 -3
View File
@@ -119,6 +119,10 @@ func (m Model) renderOverlay(base string) string {
return m.center(m.artifactsModal()) return m.center(m.artifactsModal())
case OverlayConfig: case OverlayConfig:
return m.center(m.configModal()) return m.center(m.configModal())
case OverlayProjectProfile:
return m.center(m.projectProfileModal())
case OverlayOperatorProfile:
return m.center(m.operatorProfileModal())
case OverlayStats: case OverlayStats:
return m.center(m.statsModal()) return m.center(m.statsModal())
case OverlayIdeas: case OverlayIdeas:
@@ -160,7 +164,11 @@ func (m Model) approvalBandHeight() int {
if s := m.session(m.selectedID); s != nil && s.Pending != nil { if s := m.session(m.selectedID); s != nil && s.Pending != nil {
rows = m.approvalContentRows(s.Pending) rows = m.approvalContentRows(s.Pending)
if n := len(s.Pending.Rationale); n > 0 { 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 steer := 0
@@ -219,7 +227,10 @@ func (m Model) renderApprovalBand(h int) string {
innerH := h - 2 innerH := h - 2
ratBlock := 0 ratBlock := 0
if len(a.Rationale) > 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 steerBlock := 0
if m.steering || m.steerBuffer != "" { 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), "") body = append(body, m.justify(leftHdr, rightHdr, textW, t.P.Bg), "")
for _, r := range a.Rationale { for _, r := range a.Rationale {
marker := lipgloss.NewStyle().Foreground(t.P.Warn).Background(t.P.Bg).Render("▲ ") 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 { if len(a.Rationale) > 0 {
body = append(body, "") body = append(body, "")
@@ -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
}
+26 -1
View File
@@ -151,6 +151,7 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
if s := m.session(msg.SessionID); s != nil { if s := m.session(msg.SessionID); s != nil {
s.CurrentStage = msg.StageID s.CurrentStage = msg.StageID
s.Tools = nil s.Tools = nil
s.StageTokensUsed = 0
s.addEvent(msg.OccurredAt, "StageStarted", msg.StageID) s.addEvent(msg.OccurredAt, "StageStarted", msg.StageID)
} }
case protocol.TypeStageCompleted: case protocol.TypeStageCompleted:
@@ -175,6 +176,9 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
if msg.Response != "" { if msg.Response != "" {
s.LastResponse = msg.Response s.LastResponse = msg.Response
} }
if msg.TotalTokens != nil {
s.StageTokensUsed = *msg.TotalTokens
}
s.addEvent(msg.OccurredAt, "InferenceCompleted", msg.StageID) s.addEvent(msg.OccurredAt, "InferenceCompleted", msg.StageID)
} }
case protocol.TypeInferenceTimeout: case protocol.TypeInferenceTimeout:
@@ -292,15 +296,36 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
case protocol.TypeStageManifest: case protocol.TypeStageManifest:
if s := m.session(msg.SessionID); s != nil { if s := m.session(msg.SessionID); s != nil {
byStage := map[string][]ManifestTool{} byStage := map[string][]ManifestTool{}
budgetByStage := map[string]int{}
for _, st := range msg.Stages { for _, st := range msg.Stages {
tools := make([]ManifestTool, 0, len(st.Tools)) tools := make([]ManifestTool, 0, len(st.Tools))
for _, td := range st.Tools { for _, td := range st.Tools {
tools = append(tools, ManifestTool{Name: td.Name, Tier: td.Tier}) tools = append(tools, ManifestTool{Name: td.Name, Tier: td.Tier})
} }
byStage[st.StageID] = tools byStage[st.StageID] = tools
if st.TokenBudget != nil {
budgetByStage[st.StageID] = *st.TokenBudget
}
} }
s.ToolsByStage = byStage 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: case protocol.TypeProviderStatus:
m.currentModel = msg.ProviderID m.currentModel = msg.ProviderID
if containsFold(msg.ProviderID, "llama") || containsFold(msg.ProviderID, "local") { 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.TypeModelChanged, protocol.TypeModelList, protocol.TypeResourceStatus,
protocol.TypeArtifactList, protocol.TypeConfigSnapshot, protocol.TypeSessionStats, protocol.TypeArtifactList, protocol.TypeConfigSnapshot, protocol.TypeSessionStats,
protocol.TypeIdeaList, protocol.TypeHealthChecks, protocol.TypeIdeaList, protocol.TypeHealthChecks,
protocol.TypeFileList, protocol.TypeGrantList: protocol.TypeFileList, protocol.TypeGrantList, protocol.TypeOperatorProfile:
return "" return ""
default: default:
return msg.SessionID return msg.SessionID
+11
View File
@@ -324,6 +324,10 @@ func (m Model) handleNormalKey(k keyMsg) (tea.Model, tea.Cmd) {
m.openGrants() m.openGrants()
case "g": case "g":
m.openConfig() m.openConfig()
case "P":
m.openProjectProfile()
case "O":
m.openOperatorProfile()
case "m": case "m":
m.openModelsOverlay() m.openModelsOverlay()
case "w": case "w":
@@ -658,6 +662,13 @@ func (m Model) handleOverlayKey(k keyMsg) (tea.Model, tea.Cmd) {
if m.overlay == OverlayConfig { if m.overlay == OverlayConfig {
return m.handleConfigKey(k) 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 // Sessions overlay owns all its keys: enter resumes (emits a cmd), so it can't
// share the generic no-cmd esc path below. // share the generic no-cmd esc path below.
if m.overlay == OverlaySessions { if m.overlay == OverlaySessions {
+5 -1
View File
@@ -182,7 +182,11 @@ func (m Model) renderStatus() string {
if s := m.session(m.selectedID); s != nil && m.displayState() != StateIdle { if s := m.session(m.selectedID); s != nil && m.displayState() != StateIdle {
parts = append(parts, span(s.Name, t.P.FgStrong)) parts = append(parts, span(s.Name, t.P.FgStrong))
if s.CurrentStage != "" && m.sbShow("stage") { 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") { if s.Status != "" && m.sbShow("status") {
parts = append(parts, lipgloss.NewStyle().Foreground(statusColor(t, s.Status)).Background(bg).Render(statusLabel(s.Status))) parts = append(parts, lipgloss.NewStyle().Foreground(statusColor(t, s.Status)).Background(bg).Render(statusLabel(s.Status)))
+46 -2
View File
@@ -53,6 +53,8 @@ const (
TypeRouterNarration = "router.narration" TypeRouterNarration = "router.narration"
TypeArtifactList = "artifact.list" TypeArtifactList = "artifact.list"
TypeConfigSnapshot = "config.snapshot" TypeConfigSnapshot = "config.snapshot"
TypeProjectProfile = "project_profile.snapshot"
TypeOperatorProfile = "operator_profile.snapshot"
TypeSessionStats = "session.stats" TypeSessionStats = "session.stats"
TypeIdeaList = "idea.list" TypeIdeaList = "idea.list"
TypeHealthChecks = "health.checks" TypeHealthChecks = "health.checks"
@@ -139,6 +141,15 @@ type ServerMessage struct {
ConfigRestartRequired []string `json:"restartRequired"` ConfigRestartRequired []string `json:"restartRequired"`
ConfigError *string `json:"error"` 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) // session.stats — derived metrics for a session (reply to GetSessionStats)
Stats *StatsDto `json:"stats"` Stats *StatsDto `json:"stats"`
@@ -334,8 +345,9 @@ type ApprovalDto struct {
} }
type StageToolDecl struct { type StageToolDecl struct {
StageID string `json:"stageId"` StageID string `json:"stageId"`
Tools []ToolDecl `json:"tools"` Tools []ToolDecl `json:"tools"`
TokenBudget *int `json:"tokenBudget"`
} }
type ToolDecl struct { type ToolDecl struct {
@@ -460,6 +472,38 @@ func UpdateConfig(patch map[string]string) []byte {
return encode("UpdateConfig", map[string]any{"patch": patch}) 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". // ApprovalResponse answers an approval gate. decision is "APPROVE" or "REJECT".
func ApprovalResponse(requestID, decision string, steeringNote *string) []byte { func ApprovalResponse(requestID, decision string, steeringNote *string) []byte {
return encode("ApprovalResponse", map[string]any{ return encode("ApprovalResponse", map[string]any{
@@ -21,4 +21,13 @@ data class JsonSchemaProperty(
// array is a misread brief, not a valid artifact). // array is a misread brief, not a valid artifact).
val minItems: Int = 0, val minItems: Int = 0,
val items: JsonSchemaProperty? = null, 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<String, JsonSchemaProperty> = emptyMap(),
val required: List<String> = emptyList(),
val additionalProperties: Boolean = true,
) )
@@ -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 `<homeDir>/.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>): String = "[" + v.joinToString(", ") { str(it) } + "]"
}
@@ -52,3 +52,24 @@ data class PlanCompileCheckedEvent(
/** Compiler error when [passed] is false; null on success. Kept bounded. */ /** Compiler error when [passed] is false; null on success. Kept bounded. */
val error: String? = null, val error: String? = null,
) : EventPayload ) : 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
@@ -30,6 +30,7 @@ data class InferenceCompletedEvent(
val tokensUsed: TokenUsage, val tokensUsed: TokenUsage,
val latencyMs: Long, val latencyMs: Long,
val responseArtifactId: ArtifactId, val responseArtifactId: ArtifactId,
val reasoningArtifactId: ArtifactId? = null,
) : EventPayload ) : EventPayload
@Serializable @Serializable
@@ -34,6 +34,7 @@ import com.correx.core.events.events.EgressHostsGrantedEvent
import com.correx.core.events.events.EventPayload import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.ExecutionPlanLockedEvent import com.correx.core.events.events.ExecutionPlanLockedEvent
import com.correx.core.events.events.PlanCompileCheckedEvent 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.ReviewFindingsRaisedEvent
import com.correx.core.events.events.ExecutionPlanRejectedEvent import com.correx.core.events.events.ExecutionPlanRejectedEvent
import com.correx.core.events.events.HealthDegradedEvent import com.correx.core.events.events.HealthDegradedEvent
@@ -171,6 +172,7 @@ val eventModule = SerializersModule {
subclass(ExecutionPlanLockedEvent::class) subclass(ExecutionPlanLockedEvent::class)
subclass(ExecutionPlanRejectedEvent::class) subclass(ExecutionPlanRejectedEvent::class)
subclass(PlanCompileCheckedEvent::class) subclass(PlanCompileCheckedEvent::class)
subclass(CapabilityGapDetectedEvent::class)
subclass(ReviewFindingsRaisedEvent::class) subclass(ReviewFindingsRaisedEvent::class)
subclass(JournalCompactedEvent::class) subclass(JournalCompactedEvent::class)
subclass(HealthDegradedEvent::class) subclass(HealthDegradedEvent::class)
@@ -20,6 +20,7 @@ import com.correx.core.events.events.OrchestrationResumedEvent
import com.correx.core.events.events.RefinementIterationEvent import com.correx.core.events.events.RefinementIterationEvent
import com.correx.core.events.events.RetrySalvageDecidedEvent import com.correx.core.events.events.RetrySalvageDecidedEvent
import com.correx.core.events.events.SalvageDecision 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.StoredEvent
import com.correx.core.events.events.TransitionExecutedEvent import com.correx.core.events.events.TransitionExecutedEvent
import com.correx.core.events.orchestration.OrchestrationState import com.correx.core.events.orchestration.OrchestrationState
@@ -469,6 +470,30 @@ class DefaultSessionOrchestrator(
emit(sessionId, OrchestrationResumedEvent(sessionId, stageId)) 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( private suspend fun executeMove(
ctx: EnrichedExecutionContext, ctx: EnrichedExecutionContext,
decision: TransitionDecision.Move, decision: TransitionDecision.Move,
@@ -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<ToolCapability>): 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")
}
}
@@ -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 <div>tasks</div>;\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)
}
}
@@ -50,23 +50,49 @@ object JsonSchemaValidator {
private fun checkProperty(key: String, property: JsonSchemaProperty, element: JsonElement): List<String> { private fun checkProperty(key: String, property: JsonSchemaProperty, element: JsonElement): List<String> {
val typeError = checkType(property.type, element) val typeError = checkType(property.type, element)
val array = element as? JsonArray
return when { return when {
typeError != null -> listOf("property '$key': $typeError") typeError != null -> listOf("property '$key': $typeError")
property.type != "array" || array == null -> emptyList() property.type == "array" -> checkArray(key, property, element as JsonArray)
else -> buildList { property.type == "object" && property.properties.isNotEmpty() ->
if (array.size < property.minItems) { validateProperty(property, element as JsonObject).map { "property '$key': $it" }
add("property '$key': expected at least ${property.minItems} item(s), got ${array.size}") else -> emptyList()
} }
property.items?.let { itemSchema -> }
array.forEachIndexed { i, el ->
checkType(itemSchema.type, el)?.let { add("property '$key'[$i]: $it") } private fun checkArray(key: String, property: JsonSchemaProperty, array: JsonArray): List<String> = 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<String> {
val errors = mutableListOf<String>()
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? { private fun checkType(type: String, value: JsonElement): String? {
val ok = when (type) { val ok = when (type) {
"string" -> value is JsonPrimitive && value.isString "string" -> value is JsonPrimitive && value.isString
+38
View File
@@ -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
}
+5
View File
@@ -9,6 +9,11 @@
# All four are llm_emitted: the stage's model emits JSON, which is validated against the schema # 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). # (never trusted) before the artifact counts as produced (invariant #7).
[[artifacts]]
id = "discovery"
schema_path = "schemas/discovery.json"
llm_emitted = true
[[artifacts]] [[artifacts]]
id = "analysis" id = "analysis"
schema_path = "schemas/analysis.json" schema_path = "schemas/analysis.json"
+19 -1
View File
@@ -1,5 +1,16 @@
id = "freestyle_planning" 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 # 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) # 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 token_budget = 16384
max_retries = 2 max_retries = 2
[[transitions]]
id = "discovery-to-analyst"
from = "discovery"
to = "analyst"
condition_type = "artifact_validated"
condition_artifact_id = "discovery"
[[transitions]] [[transitions]]
id = "analyst-to-architect" id = "analyst-to-architect"
from = "analyst" from = "analyst"
+3 -6
View File
@@ -22,11 +22,8 @@ Emit your result as the `analysis` artifact (JSON, schema provided):
- `requirements`: concrete requirements / acceptance criteria, one per line. - `requirements`: concrete requirements / acceptance criteria, one per line.
- `affected_areas`: files, modules, or subsystems likely involved, 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 Always produce the analysis — it is your single exit. Resolving operator forks and open questions
decision or a fork only the user can settle — add a `questions` array. Each entry is an object: is the **Discovery** stage's responsibility, upstream of you; treat the request as settled and do
`prompt` (required, the question in full), `options` (optional array of suggested answers when the not ask the user anything here. Anything you cannot answer, answer by reading the code.
answer is a choice among known alternatives), `multiSelect` (optional, default false), and `header`
(optional 12 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.
Keep it factual and grounded in what you actually read. Do not propose a solution yet. Keep it factual and grounded in what you actually read. Do not propose a solution yet.
@@ -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. 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 Always produce the analysis — this is your single exit. Open questions and operator forks are the
fork only the user can resolve), add a `questions` array. Each entry is an object: **Discovery** stage's job, and it has already run before you: any ambiguity or contradiction the
- `prompt` (required): the question, in full. user needed to resolve was raised and answered upstream, and those answers are in the decision
- `options` (optional): an array of suggested answers as strings. Offer these whenever the history above. Treat the request as settled, ground your requirements in what you actually read,
answer is a choice among known alternatives. and do not ask the user anything. Do not design or plan yet.
- `multiSelect` (optional, default false): true if more than one option may apply.
- `header` (optional): a 12 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.
@@ -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. decompose tasks here — task creation is out of scope for the plan.
- Keep stages small and single-responsibility. Prefer more stages over large monolithic - Keep stages small and single-responsibility. Prefer more stages over large monolithic
prompts. 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: **edges** — describe every transition between stages. Rules:
- `from` and `to` must each be a declared stage `id` or the literal string `"done"`. - `from` and `to` must each be a declared stage `id` or the literal string `"done"`.
+56
View File
@@ -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 12 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": [] }
```
+21 -1
View File
@@ -18,6 +18,8 @@
# #
# Requires these artifact kinds in ~/.config/correx/config.toml (schemas under docs/schemas/): # Requires these artifact kinds in ~/.config/correx/config.toml (schemas under docs/schemas/):
# [[artifacts]] # [[artifacts]]
# id = "discovery"; schema_path = "schemas/discovery.json"; llm_emitted = true
# [[artifacts]]
# id = "analysis"; schema_path = "schemas/analysis.json"; llm_emitted = true # id = "analysis"; schema_path = "schemas/analysis.json"; llm_emitted = true
# [[artifacts]] # [[artifacts]]
# id = "design"; schema_path = "schemas/design.json"; llm_emitted = true # 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. # Prompt files (prompts/*.md, relative to this workflow) must exist for a real run.
id = "role_pipeline" 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. # 1. Understand the request and the relevant code. Read-only.
# ground_references: every workspace-relative file path the analysis names is checked # ground_references: every workspace-relative file path the analysis names is checked
@@ -93,6 +106,13 @@ max_retries = 2
# --- forward edges --- # --- forward edges ---
[[transitions]]
id = "discovery-to-analyst"
from = "discovery"
to = "analyst"
condition_type = "artifact_validated"
condition_artifact_id = "discovery"
[[transitions]] [[transitions]]
id = "analyst-to-architect" id = "analyst-to-architect"
from = "analyst" from = "analyst"
@@ -145,6 +145,7 @@ class OpenAiCompatInferenceProvider(
), ),
latencyMs = System.currentTimeMillis() - startTime, latencyMs = System.currentTimeMillis() - startTime,
toolCalls = toolCalls, toolCalls = toolCalls,
reasoning = message.reasoningContent?.takeIf { it.isNotBlank() },
) )
} }
@@ -39,7 +39,9 @@ class FileWriteTool(
private val normalizedAllowedPaths: Set<Path> = allowedPaths.map { it.normalize().toAbsolutePath() }.toSet() private val normalizedAllowedPaths: Set<Path> = allowedPaths.map { it.normalize().toAbsolutePath() }.toSet()
override val name: String = "file_write" 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 { override val parametersSchema: JsonObject = buildJsonObject {
put("type", "object") put("type", "object")
putJsonObject("properties") { putJsonObject("properties") {
@@ -70,12 +70,44 @@ class ShellTool(
when (val parsed = parseArgv(request.parameters["argv"])) { when (val parsed = parseArgv(request.parameters["argv"])) {
is ArgvParse.Bad -> ValidationResult.Invalid(parsed.reason) is ArgvParse.Bad -> ValidationResult.Invalid(parsed.reason)
is ArgvParse.Ok -> when { 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 -> allowedExecutables.isNotEmpty() && parsed.argv[0] !in allowedExecutables ->
ValidationResult.Invalid("Executable '${parsed.argv[0]}' is not in the allowed list.") ValidationResult.Invalid("Executable '${parsed.argv[0]}' is not in the allowed list.")
else -> ValidationResult.Valid 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>): 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 { private sealed interface ArgvParse {
data class Ok(val argv: List<String>) : ArgvParse data class Ok(val argv: List<String>) : ArgvParse
data class Bad(val reason: String) : 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." 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_BUILTINS = setOf("cd", "export", "source", ".", "pushd", "popd", "umask", "ulimit")
val SHELL_OPERATORS = setOf("&&", "||", "|", ">", ">>", "<", ";", "&", "2>", "2>&1") 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) { private suspend fun runCmd(request: ToolRequest, process: Process): ToolResult = withTimeout(timeoutMs) {
@@ -180,4 +180,53 @@ class ShellToolTest {
fun `validateRequest accepts a clean string argv`() { fun `validateRequest accepts a clean string argv`() {
assertEquals(ValidationResult.Valid, ShellTool().validateRequest(createRequest(listOf("mkdir", "-p", "dir")))) 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)
}
} }
+1
View File
@@ -8,6 +8,7 @@ dependencies {
implementation(project(":core:inference")) implementation(project(":core:inference"))
implementation(project(":core:events")) implementation(project(":core:events"))
implementation(project(":core:artifacts")) implementation(project(":core:artifacts"))
implementation(project(":core:tools"))
implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-toml:2.17.0") implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-toml:2.17.0")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.17.0") implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.17.0")
testImplementation "org.junit.jupiter:junit-jupiter" testImplementation "org.junit.jupiter:junit-jupiter"
@@ -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<ToolCapability>,
)
/**
* 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<ToolCapability, List<String>> = 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<String, Set<ToolCapability>>): List<CapabilityGap> =
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<String, Set<ToolCapability>>,
): Set<ToolCapability> = 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)
}
@@ -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<String> = emptySet(),
produces: List<String> = emptyList(),
) = StageConfig(
produces = produces.map { TypedArtifactSlot(ArtifactId(it), kind(it)) },
allowedTools = tools,
metadata = mapOf("promptInline" to prompt),
)
private fun graph(stages: Map<String, StageConfig>, 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")
}
}
@@ -26,6 +26,7 @@ class FreestylePlanningWorkflowTest {
) )
private val registry = DefaultArtifactKindRegistry().apply { private val registry = DefaultArtifactKindRegistry().apply {
register(llmKind("discovery"))
register(llmKind("analysis")) register(llmKind("analysis"))
register(llmKind("execution_plan")) register(llmKind("execution_plan"))
} }
@@ -41,17 +42,22 @@ class FreestylePlanningWorkflowTest {
} }
@Test @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") val path = repoFile("examples/workflows/freestyle_planning.toml")
assumeTrue(path != null, "freestyle_planning.toml not found from ${System.getProperty("user.dir")}") assumeTrue(path != null, "freestyle_planning.toml not found from ${System.getProperty("user.dir")}")
val graph = TomlWorkflowLoader(registry).load(path!!) val graph = TomlWorkflowLoader(registry).load(path!!)
assertEquals(StageId("discovery"), graph.start)
assertEquals( assertEquals(
setOf("analyst", "architect"), setOf("discovery", "analyst", "architect"),
graph.stages.keys.map { it.value }.toSet(), 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")]!! val architect = graph.stages[StageId("architect")]!!
assertTrue( assertTrue(
architect.produces.any { it.name.value == "execution_plan" && it.kind.llmEmitted }, architect.produces.any { it.name.value == "execution_plan" && it.kind.llmEmitted },
@@ -68,7 +74,7 @@ class FreestylePlanningWorkflowTest {
assertEquals(StageId("done"), architectToDone.to) assertEquals(StageId("done"), architectToDone.to)
assertTrue(architectToDone.condition is ArtifactValidated) 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 // analyst frames the work: search + open one task or decompose into a graph (the architect
// threads the named task into the plan). // threads the named task into the plan).
@@ -28,6 +28,7 @@ class RolePipelineWorkflowTest {
) )
private val registry = DefaultArtifactKindRegistry().apply { private val registry = DefaultArtifactKindRegistry().apply {
register(llmKind("discovery"))
register(llmKind("analysis")) register(llmKind("analysis"))
register(llmKind("design")) register(llmKind("design"))
register(llmKind("review_report")) register(llmKind("review_report"))
@@ -54,11 +55,16 @@ class RolePipelineWorkflowTest {
assumeTrue(graph != null, "role_pipeline.toml not found from ${System.getProperty("user.dir")}") assumeTrue(graph != null, "role_pipeline.toml not found from ${System.getProperty("user.dir")}")
graph!! graph!!
assertEquals(StageId("discovery"), graph.start)
assertEquals( assertEquals(
setOf("analyst", "architect", "decomposer", "implementer", "reviewer"), setOf("discovery", "analyst", "architect", "decomposer", "implementer", "reviewer"),
graph.stages.keys.map { it.value }.toSet(), 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. // The decomposer is the mandatory task-graph gate.
assertEquals("true", graph.stages[StageId("decomposer")]!!.metadata["requireTaskDecompose"]) assertEquals("true", graph.stages[StageId("decomposer")]!!.metadata["requireTaskDecompose"])