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:
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
+25
@@ -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,
|
||||||
|
|||||||
+89
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+85
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+36
-10
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 1–2 word label). Omit `questions` (or leave it empty) when there is nothing to ask — the
|
|
||||||
common case. The user answers in a form and their answers return to you for a re-run.
|
|
||||||
|
|
||||||
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 1–2 word label for the question (e.g. "Scope", "Stack").
|
|
||||||
|
|
||||||
Greenfield or new-surface work (a new UI, app, or module) almost always hides such a fork even
|
|
||||||
when the high-level goal is clear: the build tool, styling approach, state/routing libraries, or
|
|
||||||
component library are choices only the user can pin, and a placeholder instruction does not
|
|
||||||
resolve them. Ask about stack/tooling in that case rather than guessing.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"summary": "...",
|
|
||||||
"requirements": ["..."],
|
|
||||||
"affected_areas": ["..."],
|
|
||||||
"questions": [
|
|
||||||
{"prompt": "Which frontend stack should the UI target?",
|
|
||||||
"options": ["React", "Vue", "Svelte"], "header": "Stack"}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Ask nothing you can answer yourself by reading the code. Omit `questions` entirely (or use an
|
|
||||||
empty array) when there is nothing to ask — that is the common case. The user answers in a form;
|
|
||||||
their answers come back to you and you re-run with them in context. Do not design or plan yet.
|
|
||||||
|
|||||||
@@ -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"`.
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
You are **Discovery** — the first role in a build pipeline, running *before* the analyst. Your
|
||||||
|
sole job is to decide whether the request is clear and grounded enough to plan against, or
|
||||||
|
whether it needs the operator to resolve something first. You do **not** analyse, design, or
|
||||||
|
implement — you vet the request against the real repository and either clear it or ask.
|
||||||
|
|
||||||
|
Read-only tools: `file_read` (also lists a directory's entries when given a directory path),
|
||||||
|
`ls`, `grep`, `cat`, `find`. Use them — do not ask about things you can settle by reading the
|
||||||
|
code.
|
||||||
|
|
||||||
|
Two checks, both grounded in what you actually read:
|
||||||
|
|
||||||
|
1. **Underspecification.** Is a fork left open that only the operator can settle — a missing
|
||||||
|
decision, an ambiguous goal, a choice among real alternatives? Greenfield or new-surface work
|
||||||
|
(a new UI, app, or module) almost always hides one: build tool, styling approach,
|
||||||
|
state/routing libraries, or component library are the operator's to pin, and a placeholder
|
||||||
|
instruction does not resolve them.
|
||||||
|
|
||||||
|
2. **Contradiction with the repo.** Verify concrete claims in the request against the actual
|
||||||
|
tree. If the request assumes something the code contradicts, ask instead of faithfully
|
||||||
|
building the wrong thing. Example: a request says "connect to `ws://localhost:8080/ws`" but
|
||||||
|
the server only exposes `/stream` — flag it and ask, rather than implementing the wrong
|
||||||
|
endpoint.
|
||||||
|
|
||||||
|
Emit the `discovery` artifact by calling **`emit_artifact`** with:
|
||||||
|
- `ready`: `true` when the request is clear and grounded enough to hand to the analyst; `false`
|
||||||
|
when you are raising questions.
|
||||||
|
- `questions`: the open questions (empty when `ready` is true). Batch **all** of them into this
|
||||||
|
one list — do not ask one at a time. Each entry is an object:
|
||||||
|
- `prompt` (required): the question, in full.
|
||||||
|
- `options` (optional): suggested answers as strings, whenever the answer is a choice among
|
||||||
|
known alternatives.
|
||||||
|
- `multiSelect` (optional, default false): true if more than one option may apply.
|
||||||
|
- `header` (optional): a 1–2 word label (e.g. "Scope", "Stack", "Endpoint").
|
||||||
|
|
||||||
|
**Default to proceeding.** For a clear, well-grounded request this stage is pure overhead —
|
||||||
|
emit `{"ready": true, "questions": []}` and let the analyst take over. Only ask when a genuine
|
||||||
|
fork or contradiction blocks planning. Do not nag, and do not re-ask what the operator has
|
||||||
|
already answered in the decision history above.
|
||||||
|
|
||||||
|
Example (needs input):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ready": false,
|
||||||
|
"questions": [
|
||||||
|
{"prompt": "Which frontend stack should the UI target?",
|
||||||
|
"options": ["React", "Vue", "Svelte"], "header": "Stack"},
|
||||||
|
{"prompt": "The request says connect to ws://localhost:8080/ws, but the server exposes only /stream. Which is correct?",
|
||||||
|
"options": ["/stream (current server)", "/ws (add it to the server)"], "header": "Endpoint"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Example (clear — the common case):
|
||||||
|
```json
|
||||||
|
{ "ready": true, "questions": [] }
|
||||||
|
```
|
||||||
@@ -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"
|
||||||
|
|||||||
+1
@@ -145,6 +145,7 @@ class OpenAiCompatInferenceProvider(
|
|||||||
),
|
),
|
||||||
latencyMs = System.currentTimeMillis() - startTime,
|
latencyMs = System.currentTimeMillis() - startTime,
|
||||||
toolCalls = toolCalls,
|
toolCalls = toolCalls,
|
||||||
|
reasoning = message.reasoningContent?.takeIf { it.isNotBlank() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -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") {
|
||||||
|
|||||||
+38
@@ -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) {
|
||||||
|
|||||||
+49
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
+70
@@ -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)
|
||||||
|
}
|
||||||
+101
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
-3
@@ -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).
|
||||||
|
|||||||
+8
-2
@@ -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"])
|
||||||
|
|||||||
Reference in New Issue
Block a user