wip(freestyle/acr): grounding & edit-tool fixes + ACR-compiler experiment
This branch's uncommitted WIP, committed together (entangled at file level). Distinct pieces of work: Freestyle QA fixes (this session): - FileEditTool: pre-validate replace anchor in validateRequest — reject a missing/ambiguous target BEFORE the approval gate, mirroring read/write's file-not-found / read-before-write pre-checks. Shared not-found/ambiguous messages between validate and execute so they can't drift. - PlanGrounder: add `scanned` flag; when no RepoMapComputedEvent was recorded, repoMapPaths is "unknown" not "empty workspace" — skip scope grounding (which proves a path ABSENT) so real paths (apps/server/**) aren't falsely rejected. Build-manifest check still runs. - FreestyleDriver: wire scanned=(repoMap!=null); on plan rejection emit a session-terminal WorkflowFailedEvent so a rejected run reads FAILED, not the COMPLETED-lie (last verdict was the planning-phase WorkflowCompleted). - ServerModule: resolve project-memory workspace root from the session's bound workspace (sessionWorkspaceRoot) instead of boot-static pm.repoRoot(), fixing the workspace-binding divergence (correx vs empty scratch dir). Retire tracked in Vikunja #266. - LaunchRegistrationRaceTest: join registered jobs before asserting launchCount — computeIfAbsent returns the Job immediately but the fire-and-forget launch body lagged awaitAll (the 49-vs-50 flake). ACR concept-compiler experiment (pre-existing WIP on this branch): - ExecutionPlanCompiler/Model/PlanLinter, #264 needs-seam (sessionArtifacts), LSP diagnostics subsystem (LspDiagnosticEvents/Runner/Lsp4j), BootWorkspace, config surface, workflow prompts/schemas, orchestrator advance-don't-rerun. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,7 @@ All sources under `apps/server/src/`.
|
|||||||
- `GET /metrics/tool-reliability` — per-model tool-call validity across the event log (`ToolReliabilityInspectionService`); groundwork for capability-aware routing
|
- `GET /metrics/tool-reliability` — per-model tool-call validity across the event log (`ToolReliabilityInspectionService`); groundwork for capability-aware routing
|
||||||
- Optional `[git]` transport creates `run/<sessionId>` from a server-local checkout and pushes it at terminal state; clients review with ordinary Git and never supply a remote URL as `cwd`.
|
- Optional `[git]` transport creates `run/<sessionId>` from a server-local checkout and pushes it at terminal state; clients review with ordinary Git and never supply a remote URL as `cwd`.
|
||||||
- Repo-map L3 embeddings use bounded, recorded source descriptors (module/package, imports, leading purpose comment, symbols); raw file bodies are never embedded. Their versioned `repomap:v2` namespace forces a one-time re-embed when the semantic document format changes.
|
- Repo-map L3 embeddings use bounded, recorded source descriptors (module/package, imports, leading purpose comment, symbols); raw file bodies are never embedded. Their versioned `repomap:v2` namespace forces a one-time re-embed when the semantic document format changes.
|
||||||
|
- At boot, `tools.workspace_root` is the authoritative tool jail and project-observation root. A configured `tools.working_dir` may only remain distinct when it is contained by that root; an outside value is clamped to `workspace_root`. Project memory and repo-map indexing are also rebound to `workspace_root`, so a stale `[project].root` cannot inject files from outside the session workspace.
|
||||||
|
|
||||||
### WebSocket protocol (`/ws`)
|
### WebSocket protocol (`/ws`)
|
||||||
- **ServerMessage** (server → client): sealed hierarchy — `SessionMessage` (event-derived, carries `sequence` + `sessionSequence`) and `NonEventMessage` (control/infra). Variants include session lifecycle, approval requests, clarification requests, narration, proposed workflows, health/metrics pushes.
|
- **ServerMessage** (server → client): sealed hierarchy — `SessionMessage` (event-derived, carries `sequence` + `sessionSequence`) and `NonEventMessage` (control/infra). Variants include session lifecycle, approval requests, clarification requests, narration, proposed workflows, health/metrics pushes.
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.correx.apps.server
|
||||||
|
|
||||||
|
import com.correx.core.config.ProjectConfig
|
||||||
|
import java.nio.file.Path
|
||||||
|
|
||||||
|
internal data class BootWorkspace(
|
||||||
|
val workspaceRoot: Path,
|
||||||
|
val workingDir: Path,
|
||||||
|
val workingDirWasClamped: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun resolveBootWorkspace(
|
||||||
|
explicitWorkspaceRoot: Path?,
|
||||||
|
explicitWorkingDir: Path?,
|
||||||
|
processWorkingDir: Path,
|
||||||
|
): BootWorkspace {
|
||||||
|
val workspaceRoot = (explicitWorkspaceRoot ?: explicitWorkingDir ?: processWorkingDir)
|
||||||
|
.toAbsolutePath()
|
||||||
|
.normalize()
|
||||||
|
val requestedWorkingDir = (explicitWorkingDir ?: workspaceRoot)
|
||||||
|
.toAbsolutePath()
|
||||||
|
.normalize()
|
||||||
|
val workingDirIsContained = requestedWorkingDir.startsWith(workspaceRoot)
|
||||||
|
|
||||||
|
return BootWorkspace(
|
||||||
|
workspaceRoot = workspaceRoot,
|
||||||
|
workingDir = requestedWorkingDir.takeIf { workingDirIsContained } ?: workspaceRoot,
|
||||||
|
workingDirWasClamped = !workingDirIsContained,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep repo-map observation and L3 project memory inside the authoritative boot workspace. */
|
||||||
|
internal fun ProjectConfig.boundToWorkspace(workspaceRoot: Path): ProjectConfig = copy(
|
||||||
|
root = workspaceRoot.toAbsolutePath().normalize().toString(),
|
||||||
|
)
|
||||||
@@ -83,6 +83,7 @@ import com.correx.core.events.types.SessionId
|
|||||||
import com.correx.infrastructure.InfrastructureModule
|
import com.correx.infrastructure.InfrastructureModule
|
||||||
import com.correx.infrastructure.inference.DefaultProviderRegistry
|
import com.correx.infrastructure.inference.DefaultProviderRegistry
|
||||||
import com.correx.infrastructure.workflow.ExecutionPlanCompiler
|
import com.correx.infrastructure.workflow.ExecutionPlanCompiler
|
||||||
|
import com.correx.infrastructure.workflow.Lsp4jDiagnosticsRunner
|
||||||
import com.correx.infrastructure.workflow.PlanLinter
|
import com.correx.infrastructure.workflow.PlanLinter
|
||||||
import com.correx.infrastructure.inference.CapabilityAwareRoutingStrategy
|
import com.correx.infrastructure.inference.CapabilityAwareRoutingStrategy
|
||||||
import com.correx.infrastructure.inference.commons.ManagedInferenceRouter
|
import com.correx.infrastructure.inference.commons.ManagedInferenceRouter
|
||||||
@@ -384,6 +385,7 @@ fun main() {
|
|||||||
workspacePolicy = workspacePolicy,
|
workspacePolicy = workspacePolicy,
|
||||||
workspaceToolRegistryProvider = wsToolRegistryProvider,
|
workspaceToolRegistryProvider = wsToolRegistryProvider,
|
||||||
staticAnalysisRunner = ProcessStaticAnalysisRunner(),
|
staticAnalysisRunner = ProcessStaticAnalysisRunner(),
|
||||||
|
lspDiagnosticsRunner = Lsp4jDiagnosticsRunner(),
|
||||||
contractAssertionEvaluator = FileSystemContractEvaluator(),
|
contractAssertionEvaluator = FileSystemContractEvaluator(),
|
||||||
semanticReviewer = SemanticReviewerImpl(inferenceRouter),
|
semanticReviewer = SemanticReviewerImpl(inferenceRouter),
|
||||||
)
|
)
|
||||||
@@ -464,6 +466,7 @@ fun main() {
|
|||||||
maxClarificationRounds = maxClarificationRounds,
|
maxClarificationRounds = maxClarificationRounds,
|
||||||
reviewBlockMinConfidence = reviewBlockMinConfidence,
|
reviewBlockMinConfidence = reviewBlockMinConfidence,
|
||||||
reviewBlockRetryCap = reviewBlockRetryCap,
|
reviewBlockRetryCap = reviewBlockRetryCap,
|
||||||
|
reviewLoopMaxCycles = reviewLoopMaxCycles,
|
||||||
defaultMaxRefinement = defaultMaxRefinement,
|
defaultMaxRefinement = defaultMaxRefinement,
|
||||||
recoveryRouteBudget = recoveryRouteBudget,
|
recoveryRouteBudget = recoveryRouteBudget,
|
||||||
intentRouteBudget = intentRouteBudget,
|
intentRouteBudget = intentRouteBudget,
|
||||||
|
|||||||
@@ -380,10 +380,11 @@ class ServerModule(
|
|||||||
// Record the repo map + seed prior-session memory before the run so stages
|
// Record the repo map + seed prior-session memory before the run so stages
|
||||||
// see both in context.
|
// see both in context.
|
||||||
projectMemory?.let { pm ->
|
projectMemory?.let { pm ->
|
||||||
|
val root = sessionWorkspaceRoot(sessionId)
|
||||||
runCatching {
|
runCatching {
|
||||||
pm.observeAndRecord(sessionId, pm.repoRoot())
|
pm.observeAndRecord(sessionId, root)
|
||||||
pm.indexAndRecord(sessionId, pm.repoRoot())
|
pm.indexAndRecord(sessionId, root)
|
||||||
pm.retrieveAndSeed(sessionId, pm.repoRoot())
|
pm.retrieveAndSeed(sessionId, root)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Bind operator profile snapshot as an event so replay reads the recorded
|
// Bind operator profile snapshot as an event so replay reads the recorded
|
||||||
@@ -416,7 +417,7 @@ class ServerModule(
|
|||||||
val result = orchestrator.run(sessionId, graph, sessionConfig)
|
val result = orchestrator.run(sessionId, graph, sessionConfig)
|
||||||
freestyleHandoff(sessionId, graph, result)
|
freestyleHandoff(sessionId, graph, result)
|
||||||
// Distil this run's decisions into durable project memory on completion.
|
// Distil this run's decisions into durable project memory on completion.
|
||||||
projectMemory?.let { pm -> pm.persist(sessionId, pm.repoRoot()) }
|
projectMemory?.let { pm -> pm.persist(sessionId, sessionWorkspaceRoot(sessionId)) }
|
||||||
// Propose learned profile adaptations based on session journal (opt-in, never auto-applied).
|
// Propose learned profile adaptations based on session journal (opt-in, never auto-applied).
|
||||||
operatorProfile?.let { profile ->
|
operatorProfile?.let { profile ->
|
||||||
profileAdaptationService?.let { svc ->
|
profileAdaptationService?.let { svc ->
|
||||||
@@ -498,6 +499,17 @@ class ServerModule(
|
|||||||
* router chat triage, and replay read the recorded snapshot, never the live file
|
* router chat triage, and replay read the recorded snapshot, never the live file
|
||||||
* (invariants #8/#9). Shared by the workflow path and the chat-session path.
|
* (invariants #8/#9). Shared by the workflow path and the chat-session path.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* The session's bound workspace root — the same one the tool jail and [SessionWorkspaceBoundEvent]
|
||||||
|
* use. The repo-map/index/L3-memory pipeline MUST key off this, not [ProjectMemoryService.repoRoot]
|
||||||
|
* (a session-independent config/cwd default): when they diverge the repo map is computed for a
|
||||||
|
* different tree than the session operates in, so grounding "proves" real paths absent
|
||||||
|
* (session 5fe538f5, 2026-07-19). Falls back to the server default only when unbound.
|
||||||
|
*/
|
||||||
|
private fun sessionWorkspaceRoot(sessionId: SessionId): String =
|
||||||
|
runCatching { sessionRepository.getSession(sessionId).state.boundWorkspace?.workspaceRoot }
|
||||||
|
.getOrNull() ?: projectMemory?.repoRoot() ?: "."
|
||||||
|
|
||||||
suspend fun bindProjectProfile(sessionId: SessionId) {
|
suspend fun bindProjectProfile(sessionId: SessionId) {
|
||||||
val workspaceRoot = runCatching {
|
val workspaceRoot = runCatching {
|
||||||
sessionRepository.getSession(sessionId).state.boundWorkspace?.workspaceRoot
|
sessionRepository.getSession(sessionId).state.boundWorkspace?.workspaceRoot
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.correx.apps.server.freestyle
|
package com.correx.apps.server.freestyle
|
||||||
|
|
||||||
|
import com.correx.core.events.events.ArtifactValidatedEvent
|
||||||
import com.correx.core.events.events.CapabilityGapDetectedEvent
|
import com.correx.core.events.events.CapabilityGapDetectedEvent
|
||||||
import com.correx.core.events.events.CapabilityGapReflectedEvent
|
import com.correx.core.events.events.CapabilityGapReflectedEvent
|
||||||
import com.correx.core.events.events.CapabilityGapVerdict
|
import com.correx.core.events.events.CapabilityGapVerdict
|
||||||
@@ -12,6 +13,7 @@ import com.correx.core.events.events.PlanGroundingVerdict
|
|||||||
import com.correx.core.events.events.PlanLintCompletedEvent
|
import com.correx.core.events.events.PlanLintCompletedEvent
|
||||||
import com.correx.core.events.events.ProjectProfileBoundEvent
|
import com.correx.core.events.events.ProjectProfileBoundEvent
|
||||||
import com.correx.core.events.events.RepoMapComputedEvent
|
import com.correx.core.events.events.RepoMapComputedEvent
|
||||||
|
import com.correx.core.events.events.WorkflowFailedEvent
|
||||||
import com.correx.infrastructure.workflow.PlanGrounder
|
import com.correx.infrastructure.workflow.PlanGrounder
|
||||||
import com.correx.core.tools.contract.ToolCapability
|
import com.correx.core.tools.contract.ToolCapability
|
||||||
import com.correx.infrastructure.workflow.CapabilityGap
|
import com.correx.infrastructure.workflow.CapabilityGap
|
||||||
@@ -62,7 +64,7 @@ class FreestyleDriver(
|
|||||||
emitRejected(sessionId, "no execution_plan content produced by planning phase", "missing_content")
|
emitRejected(sessionId, "no execution_plan content produced by planning phase", "missing_content")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val graph = runCatching { compiler.compile(json, "freestyle-${sessionId.value}") }
|
val graph = runCatching { compiler.compile(json, "freestyle-${sessionId.value}", sessionArtifacts(sessionId)) }
|
||||||
.getOrElse {
|
.getOrElse {
|
||||||
log.error("freestyle: plan failed to compile: {}", it.message)
|
log.error("freestyle: plan failed to compile: {}", it.message)
|
||||||
emitRejected(sessionId, "plan failed to compile: ${it.message}", "compile")
|
emitRejected(sessionId, "plan failed to compile: ${it.message}", "compile")
|
||||||
@@ -137,11 +139,17 @@ class FreestyleDriver(
|
|||||||
*/
|
*/
|
||||||
fun compiledGraph(sessionId: SessionId): WorkflowGraph? {
|
fun compiledGraph(sessionId: SessionId): WorkflowGraph? {
|
||||||
val json = planContent(sessionId) ?: return null
|
val json = planContent(sessionId) ?: return null
|
||||||
return runCatching { compiler.compile(json, "freestyle-${sessionId.value}") }
|
return runCatching { compiler.compile(json, "freestyle-${sessionId.value}", sessionArtifacts(sessionId)) }
|
||||||
.onFailure { log.error("freestyle: plan recompile for resume failed: {}", it.message) }
|
.onFailure { log.error("freestyle: plan recompile for resume failed: {}", it.message) }
|
||||||
.getOrNull()
|
.getOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Artifact ids validated earlier in the session (e.g. planning-phase `dod`) for the #264 needs seam. */
|
||||||
|
private fun sessionArtifacts(sessionId: SessionId): Set<String> =
|
||||||
|
eventStore.read(sessionId)
|
||||||
|
.mapNotNull { (it.payload as? ArtifactValidatedEvent)?.artifactId?.value }
|
||||||
|
.toSet()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Grounds [graph] against recorded workspace facts and emits [PlanGroundingEvaluatedEvent].
|
* Grounds [graph] against recorded workspace facts and emits [PlanGroundingEvaluatedEvent].
|
||||||
* Returns true if the plan was rejected (caller must stop before lock). Missing repo map/profile
|
* Returns true if the plan was rejected (caller must stop before lock). Missing repo map/profile
|
||||||
@@ -153,7 +161,10 @@ class FreestyleDriver(
|
|||||||
val repoMap = events.mapNotNull { it.payload as? RepoMapComputedEvent }.lastOrNull()
|
val repoMap = events.mapNotNull { it.payload as? RepoMapComputedEvent }.lastOrNull()
|
||||||
val profile = events.mapNotNull { it.payload as? ProjectProfileBoundEvent }.lastOrNull()
|
val profile = events.mapNotNull { it.payload as? ProjectProfileBoundEvent }.lastOrNull()
|
||||||
val paths = repoMap?.entries?.map { it.path }?.toSet().orEmpty()
|
val paths = repoMap?.entries?.map { it.path }?.toSet().orEmpty()
|
||||||
val result = PlanGrounder.ground(graph, paths, profile?.commands.orEmpty())
|
// No RepoMapComputedEvent = no scan ran, so `paths` is unknown, not "empty workspace".
|
||||||
|
// Tell the grounder not to prove paths absent from a set it never observed (the false
|
||||||
|
// "apps/server/** doesn't exist" reject); the build-manifest check still runs.
|
||||||
|
val result = PlanGrounder.ground(graph, paths, profile?.commands.orEmpty(), scanned = repoMap != null)
|
||||||
eventStore.append(
|
eventStore.append(
|
||||||
NewEvent(
|
NewEvent(
|
||||||
metadata = EventMetadata(
|
metadata = EventMetadata(
|
||||||
@@ -307,6 +318,28 @@ class FreestyleDriver(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
// A rejected plan ends the session — but the last workflow verdict on record was the
|
||||||
|
// planning phase's WorkflowCompleted, so the session read as SUCCESS (the "COMPLETED-lie").
|
||||||
|
// Emit a session-terminal WorkflowFailed so the run's true outcome is on the log. stageId is
|
||||||
|
// architect: the plan's producer and where a fix (or future return-to-architect loop) lands.
|
||||||
|
eventStore.append(
|
||||||
|
NewEvent(
|
||||||
|
metadata = EventMetadata(
|
||||||
|
eventId = EventId(UUID.randomUUID().toString()),
|
||||||
|
sessionId = sessionId,
|
||||||
|
timestamp = Clock.System.now(),
|
||||||
|
schemaVersion = 1,
|
||||||
|
causationId = null,
|
||||||
|
correlationId = null,
|
||||||
|
),
|
||||||
|
payload = WorkflowFailedEvent(
|
||||||
|
sessionId = sessionId,
|
||||||
|
stageId = StageId("architect"),
|
||||||
|
reason = "execution plan rejected ($source): $reason",
|
||||||
|
retryExhausted = false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package com.correx.apps.server
|
||||||
|
|
||||||
|
import com.correx.core.config.ProjectConfig
|
||||||
|
import java.nio.file.Path
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class BootWorkspaceTest {
|
||||||
|
@Test
|
||||||
|
fun `workspace root clamps an outside configured working directory`() {
|
||||||
|
val resolved = resolveBootWorkspace(
|
||||||
|
explicitWorkspaceRoot = Path.of("/tmp/audition"),
|
||||||
|
explicitWorkingDir = Path.of("/home/user/repo"),
|
||||||
|
processWorkingDir = Path.of("/home/user/repo"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(Path.of("/tmp/audition"), resolved.workspaceRoot)
|
||||||
|
assertEquals(Path.of("/tmp/audition"), resolved.workingDir)
|
||||||
|
assertTrue(resolved.workingDirWasClamped)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `working directory inside workspace root remains intact`() {
|
||||||
|
val resolved = resolveBootWorkspace(
|
||||||
|
explicitWorkspaceRoot = Path.of("/tmp/audition"),
|
||||||
|
explicitWorkingDir = Path.of("/tmp/audition/frontend"),
|
||||||
|
processWorkingDir = Path.of("/home/user/repo"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(Path.of("/tmp/audition"), resolved.workspaceRoot)
|
||||||
|
assertEquals(Path.of("/tmp/audition/frontend"), resolved.workingDir)
|
||||||
|
assertFalse(resolved.workingDirWasClamped)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `configured working directory supplies the root when workspace root is absent`() {
|
||||||
|
val resolved = resolveBootWorkspace(
|
||||||
|
explicitWorkspaceRoot = null,
|
||||||
|
explicitWorkingDir = Path.of("/tmp/configured"),
|
||||||
|
processWorkingDir = Path.of("/home/user/repo"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(Path.of("/tmp/configured"), resolved.workspaceRoot)
|
||||||
|
assertEquals(Path.of("/tmp/configured"), resolved.workingDir)
|
||||||
|
assertFalse(resolved.workingDirWasClamped)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `project memory root follows the authoritative workspace root`() {
|
||||||
|
val configured = ProjectConfig(
|
||||||
|
enabled = true,
|
||||||
|
root = "/home/user/repo",
|
||||||
|
)
|
||||||
|
|
||||||
|
val resolved = configured.boundToWorkspace(Path.of("/tmp/audition/../audition"))
|
||||||
|
|
||||||
|
assertEquals("/tmp/audition", resolved.root)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ CORREX kernel team. Config schema changes affect all consumers — coordinate wi
|
|||||||
- `ConfigHolder` is the shared mutable reference injected into consumers. Never read the file directly in domain code — always go through `ConfigHolder`.
|
- `ConfigHolder` is the shared mutable reference injected into consumers. Never read the file directly in domain code — always go through `ConfigHolder`.
|
||||||
- `CorrexConfigWriter` regenerates TOML from the in-memory model; round-tripping loses comments by design.
|
- `CorrexConfigWriter` regenerates TOML from the in-memory model; round-tripping loses comments by design.
|
||||||
- `[git]` is opt-in server transport configuration (`enabled`, `remote`, `base_branch`, optional `author`); it is off by default.
|
- `[git]` is opt-in server transport configuration (`enabled`, `remote`, `base_branch`, optional `author`); it is off by default.
|
||||||
|
- `[orchestration].review_loop_max_cycles` bounds review→rework cycles before recovery escalation; default `3`.
|
||||||
- `ModelConfig.contextSize` defaults to 24,576 tokens; explicit `context_size` remains the operator override for smaller local-model windows.
|
- `ModelConfig.contextSize` defaults to 24,576 tokens; explicit `context_size` remains the operator override for smaller local-model windows.
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|||||||
@@ -217,6 +217,7 @@ object ConfigLoader {
|
|||||||
private const val DEFAULT_MAX_CLARIFICATION_ROUNDS = 3
|
private const val DEFAULT_MAX_CLARIFICATION_ROUNDS = 3
|
||||||
private const val DEFAULT_REVIEW_BLOCK_MIN_CONFIDENCE = 0.7
|
private const val DEFAULT_REVIEW_BLOCK_MIN_CONFIDENCE = 0.7
|
||||||
private const val DEFAULT_REVIEW_BLOCK_RETRY_CAP = 20
|
private const val DEFAULT_REVIEW_BLOCK_RETRY_CAP = 20
|
||||||
|
private const val DEFAULT_REVIEW_LOOP_MAX_CYCLES = 3
|
||||||
private const val DEFAULT_MAX_REFINEMENT = 3
|
private const val DEFAULT_MAX_REFINEMENT = 3
|
||||||
private const val DEFAULT_RECOVERY_ROUTE_BUDGET = 2
|
private const val DEFAULT_RECOVERY_ROUTE_BUDGET = 2
|
||||||
private const val DEFAULT_INTENT_ROUTE_BUDGET = 2
|
private const val DEFAULT_INTENT_ROUTE_BUDGET = 2
|
||||||
@@ -716,6 +717,8 @@ object ConfigLoader {
|
|||||||
reviewBlockMinConfidence =
|
reviewBlockMinConfidence =
|
||||||
asDouble(orchestrationSection["review_block_min_confidence"], DEFAULT_REVIEW_BLOCK_MIN_CONFIDENCE),
|
asDouble(orchestrationSection["review_block_min_confidence"], DEFAULT_REVIEW_BLOCK_MIN_CONFIDENCE),
|
||||||
reviewBlockRetryCap = asInt(orchestrationSection["review_block_retry_cap"], DEFAULT_REVIEW_BLOCK_RETRY_CAP),
|
reviewBlockRetryCap = asInt(orchestrationSection["review_block_retry_cap"], DEFAULT_REVIEW_BLOCK_RETRY_CAP),
|
||||||
|
reviewLoopMaxCycles =
|
||||||
|
asInt(orchestrationSection["review_loop_max_cycles"], DEFAULT_REVIEW_LOOP_MAX_CYCLES),
|
||||||
defaultMaxRefinement = asInt(orchestrationSection["default_max_refinement"], DEFAULT_MAX_REFINEMENT),
|
defaultMaxRefinement = asInt(orchestrationSection["default_max_refinement"], DEFAULT_MAX_REFINEMENT),
|
||||||
recoveryRouteBudget = asInt(orchestrationSection["recovery_route_budget"], DEFAULT_RECOVERY_ROUTE_BUDGET),
|
recoveryRouteBudget = asInt(orchestrationSection["recovery_route_budget"], DEFAULT_RECOVERY_ROUTE_BUDGET),
|
||||||
intentRouteBudget = asInt(orchestrationSection["intent_route_budget"], DEFAULT_INTENT_ROUTE_BUDGET),
|
intentRouteBudget = asInt(orchestrationSection["intent_route_budget"], DEFAULT_INTENT_ROUTE_BUDGET),
|
||||||
|
|||||||
@@ -130,6 +130,8 @@ data class OrchestrationKnobs(
|
|||||||
val maxClarificationRounds: Int = 3,
|
val maxClarificationRounds: Int = 3,
|
||||||
val reviewBlockMinConfidence: Double = 0.7,
|
val reviewBlockMinConfidence: Double = 0.7,
|
||||||
val reviewBlockRetryCap: Int = 20,
|
val reviewBlockRetryCap: Int = 20,
|
||||||
|
/** Review→rework cycles before deterministic escalation to the recovery stage. */
|
||||||
|
val reviewLoopMaxCycles: Int = 3,
|
||||||
val defaultMaxRefinement: Int = 3,
|
val defaultMaxRefinement: Int = 3,
|
||||||
val recoveryRouteBudget: Int = 2,
|
val recoveryRouteBudget: Int = 2,
|
||||||
val intentRouteBudget: Int = 2,
|
val intentRouteBudget: Int = 2,
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ object CorrexConfigWriter {
|
|||||||
b.kv("max_clarification_rounds", cfg.orchestration.maxClarificationRounds)
|
b.kv("max_clarification_rounds", cfg.orchestration.maxClarificationRounds)
|
||||||
b.kv("review_block_min_confidence", cfg.orchestration.reviewBlockMinConfidence)
|
b.kv("review_block_min_confidence", cfg.orchestration.reviewBlockMinConfidence)
|
||||||
b.kv("review_block_retry_cap", cfg.orchestration.reviewBlockRetryCap)
|
b.kv("review_block_retry_cap", cfg.orchestration.reviewBlockRetryCap)
|
||||||
|
b.kv("review_loop_max_cycles", cfg.orchestration.reviewLoopMaxCycles)
|
||||||
b.kv("default_max_refinement", cfg.orchestration.defaultMaxRefinement)
|
b.kv("default_max_refinement", cfg.orchestration.defaultMaxRefinement)
|
||||||
b.kv("recovery_route_budget", cfg.orchestration.recoveryRouteBudget)
|
b.kv("recovery_route_budget", cfg.orchestration.recoveryRouteBudget)
|
||||||
b.kv("intent_route_budget", cfg.orchestration.intentRouteBudget)
|
b.kv("intent_route_budget", cfg.orchestration.intentRouteBudget)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ CORREX kernel team. This is the most cross-cutting module in the codebase — ch
|
|||||||
- `JsonEventSerializer` / `EventSerializer` — serialize/deserialize `StoredEvent` to JSON.
|
- `JsonEventSerializer` / `EventSerializer` — serialize/deserialize `StoredEvent` to JSON.
|
||||||
- `EventDispatcher` — broadcasts events to in-process listeners.
|
- `EventDispatcher` — broadcasts events to in-process listeners.
|
||||||
- Domain event files: `ApprovalEvents`, `ArtifactEvents`, `ContextEvents`, `InferenceEvents`, `OrchestrationEvents`, `RouterEvents`, `SessionEvents`, `TaskEvents`, `ToolEvents`, `IntentEvents`, `RiskAssessedEvent`, `JournalCompactedEvent`, and many more — all payload definitions live here.
|
- Domain event files: `ApprovalEvents`, `ArtifactEvents`, `ContextEvents`, `InferenceEvents`, `OrchestrationEvents`, `RouterEvents`, `SessionEvents`, `TaskEvents`, `ToolEvents`, `IntentEvents`, `RiskAssessedEvent`, `JournalCompactedEvent`, and many more — all payload definitions live here.
|
||||||
|
- `LspDiagnosticsCompletedEvent` records pulled language-server diagnostics or a graceful skip reason; replay consumes this observation and never contacts the server.
|
||||||
- Shared vocabulary: `IdentityTypes` (SessionId, TaskId, etc.), `Tier`, `TokenUsage`, `ToolReceipt`, `ToolRequest`, `RiskLevel`, `RetryPolicy`, `GrantScope`, `GrantLedger`.
|
- Shared vocabulary: `IdentityTypes` (SessionId, TaskId, etc.), `Tier`, `TokenUsage`, `ToolReceipt`, `ToolRequest`, `RiskLevel`, `RetryPolicy`, `GrantScope`, `GrantLedger`.
|
||||||
|
|
||||||
## Work Guidance
|
## Work Guidance
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.correx.core.events.events
|
||||||
|
|
||||||
|
import com.correx.core.events.types.SessionId
|
||||||
|
import com.correx.core.events.types.StageId
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class LspDiagnostic(
|
||||||
|
val path: String,
|
||||||
|
val line: Int,
|
||||||
|
val character: Int,
|
||||||
|
val severity: String,
|
||||||
|
val code: String? = null,
|
||||||
|
val message: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Recorded LSP 3.17 pull-diagnostic observation; replay never re-queries a language server. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("LspDiagnosticsCompleted")
|
||||||
|
data class LspDiagnosticsCompletedEvent(
|
||||||
|
val sessionId: SessionId,
|
||||||
|
val stageId: StageId,
|
||||||
|
val server: String?,
|
||||||
|
val diagnostics: List<LspDiagnostic>,
|
||||||
|
val skippedReason: String? = null,
|
||||||
|
) : EventPayload
|
||||||
@@ -16,6 +16,7 @@ import com.correx.core.events.events.BriefEchoMismatchEvent
|
|||||||
import com.correx.core.events.events.BriefGroundingCheckedEvent
|
import com.correx.core.events.events.BriefGroundingCheckedEvent
|
||||||
import com.correx.core.events.events.ConceptPromotedEvent
|
import com.correx.core.events.events.ConceptPromotedEvent
|
||||||
import com.correx.core.events.events.StaticAnalysisCompletedEvent
|
import com.correx.core.events.events.StaticAnalysisCompletedEvent
|
||||||
|
import com.correx.core.events.events.LspDiagnosticsCompletedEvent
|
||||||
import com.correx.core.events.events.ContractGateEvaluatedEvent
|
import com.correx.core.events.events.ContractGateEvaluatedEvent
|
||||||
import com.correx.core.events.events.PlanLintCompletedEvent
|
import com.correx.core.events.events.PlanLintCompletedEvent
|
||||||
import com.correx.core.events.events.ChatSessionStartedEvent
|
import com.correx.core.events.events.ChatSessionStartedEvent
|
||||||
@@ -173,6 +174,7 @@ val eventModule = SerializersModule {
|
|||||||
subclass(BriefGroundingCheckedEvent::class)
|
subclass(BriefGroundingCheckedEvent::class)
|
||||||
subclass(BriefEchoMismatchEvent::class)
|
subclass(BriefEchoMismatchEvent::class)
|
||||||
subclass(StaticAnalysisCompletedEvent::class)
|
subclass(StaticAnalysisCompletedEvent::class)
|
||||||
|
subclass(LspDiagnosticsCompletedEvent::class)
|
||||||
subclass(ContractGateEvaluatedEvent::class)
|
subclass(ContractGateEvaluatedEvent::class)
|
||||||
subclass(PlanLintCompletedEvent::class)
|
subclass(PlanLintCompletedEvent::class)
|
||||||
subclass(RiskAssessedEvent::class)
|
subclass(RiskAssessedEvent::class)
|
||||||
|
|||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
package com.correx.core.events.serialization
|
||||||
|
|
||||||
|
import com.correx.core.events.events.LspDiagnostic
|
||||||
|
import com.correx.core.events.events.LspDiagnosticsCompletedEvent
|
||||||
|
import com.correx.core.events.types.SessionId
|
||||||
|
import com.correx.core.events.types.StageId
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertIs
|
||||||
|
|
||||||
|
class LspDiagnosticsCompletedEventSerializationTest {
|
||||||
|
@Test
|
||||||
|
fun `round-trips as polymorphic EventPayload`() {
|
||||||
|
val event = LspDiagnosticsCompletedEvent(
|
||||||
|
SessionId("s"), StageId("impl"), "tsserver",
|
||||||
|
listOf(LspDiagnostic("src/App.tsx", 1, 2, "error", "2322", "not assignable")),
|
||||||
|
)
|
||||||
|
val encoded = eventJson.encodeToString(com.correx.core.events.events.EventPayload.serializer(), event)
|
||||||
|
val decoded = eventJson.decodeFromString(com.correx.core.events.events.EventPayload.serializer(), encoded)
|
||||||
|
assertIs<LspDiagnosticsCompletedEvent>(decoded)
|
||||||
|
assertEquals("src/App.tsx", decoded.diagnostics.single().path)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,8 @@ CORREX kernel team. This is the integration point for all other `core/` modules.
|
|||||||
- `ReplayOrchestrator` / `ReplayInferenceProvider` / `ReplayStrategy` — deterministic replay of a session from its event log. `ReplayInferenceProvider` returns recorded responses — no live LLM (Hard Invariant #8).
|
- `ReplayOrchestrator` / `ReplayInferenceProvider` / `ReplayStrategy` — deterministic replay of a session from its event log. `ReplayInferenceProvider` returns recorded responses — no live LLM (Hard Invariant #8).
|
||||||
- `SubagentRunner` / `InSessionSubagentRunner` — runs sub-agent invocations within an active session.
|
- `SubagentRunner` / `InSessionSubagentRunner` — runs sub-agent invocations within an active session.
|
||||||
- `StaticAnalysisRunner` / `ProcessStaticAnalysisRunner` — runs static analysis tools and records results as events.
|
- `StaticAnalysisRunner` / `ProcessStaticAnalysisRunner` — runs static analysis tools and records results as events.
|
||||||
|
- `LspDiagnosticsRunner` — injected pull-diagnostics seam; diagnostics are filtered to stage-written files, recorded, and enforced before build/review.
|
||||||
|
- Review→rework loops use the configured three-cycle default, then route accumulated notes to recovery once and fail if the fixed DoD still cannot be approved.
|
||||||
- `StageCheckpointReconciler` — reconciles checkpoint state across stage transitions.
|
- `StageCheckpointReconciler` — reconciles checkpoint state across stage transitions.
|
||||||
- Capability-gated failures first retry in place when the stage holds the required tool; an unchanged-fingerprint gate-budget exhaustion routes to the recovery/intent-holder stage when one is available, so capability possession alone cannot cause a frozen owner loop to fail the workflow.
|
- Capability-gated failures first retry in place when the stage holds the required tool; an unchanged-fingerprint gate-budget exhaustion routes to the recovery/intent-holder stage when one is available, so capability possession alone cannot cause a frozen owner loop to fail the workflow.
|
||||||
- Three repeated `REFERENCE_EXISTS` blocks for the same build prerequisite within one stage become a `workspace_precondition` gate failure, which is eligible for file-write recovery rather than remaining disconnected tool-call noise.
|
- Three repeated `REFERENCE_EXISTS` blocks for the same build prerequisite within one stage become a `workspace_precondition` gate failure, which is eligible for file-write recovery rather than remaining disconnected tool-call noise.
|
||||||
|
|||||||
+12
-4
@@ -15,6 +15,7 @@ import com.correx.core.events.events.OrchestrationResumedEvent
|
|||||||
import com.correx.core.events.events.SteeringNoteAddedEvent
|
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.events.WorkflowStartedEvent
|
||||||
import com.correx.core.events.orchestration.OrchestrationState
|
import com.correx.core.events.orchestration.OrchestrationState
|
||||||
import com.correx.core.events.types.ApprovalDecisionId
|
import com.correx.core.events.types.ApprovalDecisionId
|
||||||
import com.correx.core.events.types.ApprovalRequestId
|
import com.correx.core.events.types.ApprovalRequestId
|
||||||
@@ -226,8 +227,8 @@ class DefaultSessionOrchestrator(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Delivers the operator's answers to a pending clarification raised by a stage. The waiting
|
* Delivers the operator's answers to a pending clarification raised by a stage. The waiting
|
||||||
* stage (parked in [requestClarificationIfNeeded]) completes and re-runs with the answers in
|
* stage (parked in [requestClarificationIfNeeded]) unparks and the run advances to the next
|
||||||
* context. If the server restarted while the clarification was pending there is no live
|
* stage with the answers in context. If the server restarted while the clarification was pending there is no live
|
||||||
* coroutine to complete, so the answers are recorded directly and the session resumed.
|
* coroutine to complete, so the answers are recorded directly and the session resumed.
|
||||||
*/
|
*/
|
||||||
suspend fun submitClarification(
|
suspend fun submitClarification(
|
||||||
@@ -280,10 +281,17 @@ class DefaultSessionOrchestrator(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// A back-edge re-enters a stage already transitioned into this run (e.g. reviewer→implementer).
|
// A back-edge re-enters any stage already visited this run (e.g. reviewer→implementer). The start
|
||||||
|
// stage is visited via WorkflowStarted rather than TransitionExecuted, so it must participate too.
|
||||||
// Top-level (not a member) to keep the orchestrator off the TooManyFunctions threshold.
|
// Top-level (not a member) to keep the orchestrator off the TooManyFunctions threshold.
|
||||||
internal fun isBackEdge(events: List<StoredEvent>, target: StageId): Boolean =
|
internal fun isBackEdge(events: List<StoredEvent>, target: StageId): Boolean =
|
||||||
events.any { (it.payload as? TransitionExecutedEvent)?.to == target }
|
events.any {
|
||||||
|
when (val payload = it.payload) {
|
||||||
|
is WorkflowStartedEvent -> payload.startStageId == target
|
||||||
|
is TransitionExecutedEvent -> payload.to == target
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
internal sealed class StepResult {
|
internal sealed class StepResult {
|
||||||
data class Continue(val ctx: EnrichedExecutionContext) : StepResult()
|
data class Continue(val ctx: EnrichedExecutionContext) : StepResult()
|
||||||
|
|||||||
+45
-6
@@ -5,6 +5,7 @@ import com.correx.core.artifacts.ArtifactState
|
|||||||
import com.correx.core.events.events.ApprovalDecisionResolvedEvent
|
import com.correx.core.events.events.ApprovalDecisionResolvedEvent
|
||||||
import com.correx.core.events.events.ApprovalRequestedEvent
|
import com.correx.core.events.events.ApprovalRequestedEvent
|
||||||
import com.correx.core.events.events.ArtifactValidatedEvent
|
import com.correx.core.events.events.ArtifactValidatedEvent
|
||||||
|
import com.correx.core.events.events.FailureTicketOpenedEvent
|
||||||
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
|
||||||
@@ -135,6 +136,7 @@ internal tailrec suspend fun DefaultSessionOrchestrator.step(ctx: EnrichedExecut
|
|||||||
* re-execute the same stage if attempts remain, else fail terminally with retryExhausted=true.
|
* re-execute the same stage if attempts remain, else fail terminally with retryExhausted=true.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@Suppress("LongMethod", "ReturnCount", "NestedBlockDepth")
|
||||||
internal suspend fun DefaultSessionOrchestrator.executeMove(
|
internal suspend fun DefaultSessionOrchestrator.executeMove(
|
||||||
ctx: EnrichedExecutionContext,
|
ctx: EnrichedExecutionContext,
|
||||||
decision: TransitionDecision.Move,
|
decision: TransitionDecision.Move,
|
||||||
@@ -154,10 +156,43 @@ internal suspend fun DefaultSessionOrchestrator.executeMove(
|
|||||||
// terminal failure instead of looping forever.
|
// terminal failure instead of looping forever.
|
||||||
if (isBackEdge(repositories.eventStore.read(ctx.sessionId), nextStageId)) {
|
if (isBackEdge(repositories.eventStore.read(ctx.sessionId), nextStageId)) {
|
||||||
val cycleKey = "${ctx.currentStageId.value}->${nextStageId.value}"
|
val cycleKey = "${ctx.currentStageId.value}->${nextStageId.value}"
|
||||||
val maxIterations = ctx.graph.stages[nextStageId]?.maxRetries ?: tuning.defaultMaxRefinement
|
val reviewerRole = ctx.graph.stages[ctx.currentStageId]?.metadata?.get("role")?.lowercase()
|
||||||
|
val reviewLoop = reviewerRole in setOf("review", "reviewer")
|
||||||
|
val maxIterations = if (reviewLoop) {
|
||||||
|
tuning.reviewLoopMaxCycles
|
||||||
|
} else {
|
||||||
|
ctx.graph.stages[nextStageId]?.maxRetries ?: tuning.defaultMaxRefinement
|
||||||
|
}
|
||||||
val iteration = (orchestrationRepository.getState(ctx.sessionId).refinementIterations[cycleKey] ?: 0) + 1
|
val iteration = (orchestrationRepository.getState(ctx.sessionId).refinementIterations[cycleKey] ?: 0) + 1
|
||||||
emit(ctx.sessionId, RefinementIterationEvent(ctx.sessionId, cycleKey, iteration, maxIterations))
|
emit(ctx.sessionId, RefinementIterationEvent(ctx.sessionId, cycleKey, iteration, maxIterations))
|
||||||
if (iteration > maxIterations) {
|
if (iteration > maxIterations) {
|
||||||
|
if (reviewLoop) {
|
||||||
|
val events = repositories.eventStore.read(ctx.sessionId)
|
||||||
|
val alreadyRecovered = events.mapNotNull { it.payload as? FailureTicketOpenedEvent }
|
||||||
|
.any { it.gate == REVIEW_LOOP_GATE && it.stageId == ctx.currentStageId }
|
||||||
|
val notes = ctx.graph.stages[ctx.currentStageId]?.produces
|
||||||
|
?.mapNotNull { artifactContentCache["${ctx.sessionId.value}:${it.name.value}"] }
|
||||||
|
?.joinToString("\n\n")
|
||||||
|
.orEmpty()
|
||||||
|
val reason = "review loop exhausted after exactly $maxIterations cycles. " +
|
||||||
|
"The fixed DoD was not approved. Accumulated review notes:\n" +
|
||||||
|
notes.ifBlank { "(review stage emitted no retained notes)" }
|
||||||
|
if (!alreadyRecovered) {
|
||||||
|
return routeToRecovery(
|
||||||
|
ctx,
|
||||||
|
ctx.currentStageId,
|
||||||
|
REVIEW_LOOP_GATE,
|
||||||
|
"review_convergence",
|
||||||
|
reason,
|
||||||
|
orchestrationRepository.getState(ctx.sessionId),
|
||||||
|
) ?: StepResult.Terminal(
|
||||||
|
failWorkflow(ctx.sessionId, ctx.currentStageId, reason, retryExhausted = true),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return StepResult.Terminal(
|
||||||
|
failWorkflow(ctx.sessionId, ctx.currentStageId, reason, retryExhausted = true),
|
||||||
|
)
|
||||||
|
}
|
||||||
return StepResult.Terminal(
|
return StepResult.Terminal(
|
||||||
failWorkflow(
|
failWorkflow(
|
||||||
ctx.sessionId,
|
ctx.sessionId,
|
||||||
@@ -179,6 +214,8 @@ internal suspend fun DefaultSessionOrchestrator.executeMove(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private const val REVIEW_LOOP_GATE = "review_loop"
|
||||||
|
|
||||||
@Suppress("ReturnCount")
|
@Suppress("ReturnCount")
|
||||||
internal suspend fun DefaultSessionOrchestrator.enterStage(
|
internal suspend fun DefaultSessionOrchestrator.enterStage(
|
||||||
ctx: EnrichedExecutionContext,
|
ctx: EnrichedExecutionContext,
|
||||||
@@ -224,11 +261,13 @@ internal suspend fun DefaultSessionOrchestrator.enterStage(
|
|||||||
).outcome
|
).outcome
|
||||||
when (result) {
|
when (result) {
|
||||||
is StageExecutionResult.Success -> {
|
is StageExecutionResult.Success -> {
|
||||||
if (requestClarificationIfNeeded(ctx.sessionId, stageId, ctx.graph)) {
|
// The stage may emit open questions in its artifact (discovery does). Park, let the
|
||||||
// The stage raised open questions and the operator answered them; loop to
|
// operator answer, and record the answers — then ADVANCE, do not re-run the stage.
|
||||||
// re-run the stage with the answers injected (no failure-retry budget spent).
|
// A re-run restarts inference with no prior CoT, so the stage re-explores instead of
|
||||||
continue
|
// converging (2026-07-18). The answers are recorded as ClarificationAnsweredEvents
|
||||||
}
|
// and injected into every later stage's L0 context (buildClarificationAnswerEntries),
|
||||||
|
// so the next stage (e.g. analyst) sees them without discovery running again.
|
||||||
|
requestClarificationIfNeeded(ctx.sessionId, stageId, ctx.graph)
|
||||||
compactionService?.let { svc ->
|
compactionService?.let { svc ->
|
||||||
val journalState = decisionJournalRepository.getJournal(ctx.sessionId)
|
val journalState = decisionJournalRepository.getJournal(ctx.sessionId)
|
||||||
val journalText = DecisionJournalRenderer().render(journalState)
|
val journalText = DecisionJournalRenderer().render(journalState)
|
||||||
|
|||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
package com.correx.core.kernel.orchestration
|
||||||
|
|
||||||
|
import com.correx.core.events.events.LspDiagnostic
|
||||||
|
import java.nio.file.Path
|
||||||
|
|
||||||
|
data class LspDiagnosticsRequest(
|
||||||
|
val workspaceRoot: Path,
|
||||||
|
val paths: List<String>,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class LspDiagnosticsResult(
|
||||||
|
val server: String? = null,
|
||||||
|
val diagnostics: List<LspDiagnostic> = emptyList(),
|
||||||
|
val skippedReason: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Nondeterministic LSP boundary. The orchestrator records its result before using it. */
|
||||||
|
fun interface LspDiagnosticsRunner {
|
||||||
|
suspend fun pull(request: LspDiagnosticsRequest): LspDiagnosticsResult
|
||||||
|
}
|
||||||
+2
@@ -35,6 +35,8 @@ data class OrchestrationTuning(
|
|||||||
val reviewBlockMinConfidence: Double = 0.7,
|
val reviewBlockMinConfidence: Double = 0.7,
|
||||||
/** Pathological backstop: max review-driven retries before the stage is let through. */
|
/** Pathological backstop: max review-driven retries before the stage is let through. */
|
||||||
val reviewBlockRetryCap: Int = 20,
|
val reviewBlockRetryCap: Int = 20,
|
||||||
|
/** Review→rework cycles before deterministic escalation to recovery. */
|
||||||
|
val reviewLoopMaxCycles: Int = 3,
|
||||||
/** Max review→refine cycles for a stage (freestyle default refinement budget). */
|
/** Max review→refine cycles for a stage (freestyle default refinement budget). */
|
||||||
val defaultMaxRefinement: Int = 3,
|
val defaultMaxRefinement: Int = 3,
|
||||||
/** Budget for routing a failed write-less stage to a recovery stage. */
|
/** Budget for routing a failed write-less stage to a recovery stage. */
|
||||||
|
|||||||
+1
@@ -31,6 +31,7 @@ data class OrchestratorEngines(
|
|||||||
// Runs operator-configured static-analysis commands as a harness step (role-reliability §5).
|
// Runs operator-configured static-analysis commands as a harness step (role-reliability §5).
|
||||||
// Null = no static-first step; a stage declaring `static_analysis` then no-ops with a warning.
|
// Null = no static-first step; a stage declaring `static_analysis` then no-ops with a warning.
|
||||||
val staticAnalysisRunner: StaticAnalysisRunner? = null,
|
val staticAnalysisRunner: StaticAnalysisRunner? = null,
|
||||||
|
val lspDiagnosticsRunner: LspDiagnosticsRunner? = null,
|
||||||
// Evaluates Gate 2 contract assertions against a stage's produced files (design §1). Null = no
|
// Evaluates Gate 2 contract assertions against a stage's produced files (design §1). Null = no
|
||||||
// contract gate; the deterministic funnel then rests on produces-presence + static analysis only.
|
// contract gate; the deterministic funnel then rests on produces-presence + static analysis only.
|
||||||
val contractAssertionEvaluator: ContractAssertionEvaluator? = null,
|
val contractAssertionEvaluator: ContractAssertionEvaluator? = null,
|
||||||
|
|||||||
+49
-9
@@ -4,6 +4,7 @@ import com.correx.core.tools.process.ChildProcess
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.TimeoutCancellationException
|
import kotlinx.coroutines.TimeoutCancellationException
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.coroutines.withTimeout
|
import kotlinx.coroutines.withTimeout
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
@@ -26,31 +27,70 @@ class ProcessStaticAnalysisRunner(
|
|||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val argv = command.trim().split(WHITESPACE).filter { it.isNotEmpty() }
|
val argv = command.trim().split(WHITESPACE).filter { it.isNotEmpty() }
|
||||||
if (argv.isEmpty()) return@withContext StaticAnalysisRunResult(EXIT_NOT_RUN, "empty command")
|
if (argv.isEmpty()) return@withContext StaticAnalysisRunResult(EXIT_NOT_RUN, "empty command")
|
||||||
|
if (argv.first() == STATIC_FLOOR_COMMAND) return@withContext runStaticFloor(workingDir, argv)
|
||||||
|
runProcess(workingDir, argv, command)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("ReturnCount")
|
||||||
|
private suspend fun runStaticFloor(workingDir: Path, argv: List<String>): StaticAnalysisRunResult {
|
||||||
|
val paths = argv.dropWhile { it != "--" }.drop(1)
|
||||||
|
if (paths.isEmpty()) return StaticAnalysisRunResult(0, "static floor skipped: no concrete files")
|
||||||
|
val checks = paths.mapNotNull { path -> checkerFor(path)?.let { it + path } }
|
||||||
|
if (checks.isEmpty()) {
|
||||||
|
return StaticAnalysisRunResult(0, "static floor skipped: no resolvable checker for ${paths.joinToString()}")
|
||||||
|
}
|
||||||
|
val outputs = mutableListOf<String>()
|
||||||
|
for (check in checks) {
|
||||||
|
val result = runProcess(workingDir, check, check.joinToString(" "))
|
||||||
|
outputs += "${check.joinToString(" ")}: ${result.output}".trim()
|
||||||
|
if (result.exitCode != 0) return StaticAnalysisRunResult(result.exitCode, outputs.joinToString("\n"))
|
||||||
|
}
|
||||||
|
val skipped = paths.size - checks.size
|
||||||
|
if (skipped > 0) outputs += "static floor skipped $skipped file(s) without a checker"
|
||||||
|
return StaticAnalysisRunResult(0, outputs.joinToString("\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkerFor(path: String): List<String>? = when (path.substringAfterLast('.', "").lowercase()) {
|
||||||
|
"js", "mjs", "cjs" -> listOf("node", "--check")
|
||||||
|
"py" -> listOf("python3", "-m", "py_compile")
|
||||||
|
"sh", "bash" -> listOf("bash", "-n")
|
||||||
|
"rb" -> listOf("ruby", "-c")
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun runProcess(
|
||||||
|
workingDir: Path,
|
||||||
|
argv: List<String>,
|
||||||
|
displayCommand: String,
|
||||||
|
): StaticAnalysisRunResult {
|
||||||
val process = runCatching {
|
val process = runCatching {
|
||||||
ChildProcess.builder(argv, workingDir.toFile()).redirectErrorStream(true).start()
|
ChildProcess.builder(argv, workingDir.toFile()).redirectErrorStream(true).start()
|
||||||
}.getOrElse {
|
}.getOrElse {
|
||||||
return@withContext StaticAnalysisRunResult(EXIT_NOT_RUN, "failed to start '$command': ${it.message}")
|
return StaticAnalysisRunResult(EXIT_NOT_RUN, "failed to start '$displayCommand': ${it.message}")
|
||||||
}
|
}
|
||||||
runCatching {
|
return runCatching {
|
||||||
withTimeout(timeoutMs) {
|
coroutineScope {
|
||||||
val outputDeferred = async { process.inputStream.bufferedReader().use { it.readText() } }
|
withTimeout(timeoutMs) {
|
||||||
val exit = process.waitFor()
|
val outputDeferred = async { process.inputStream.bufferedReader().use { it.readText() } }
|
||||||
StaticAnalysisRunResult(exit, outputDeferred.await())
|
val exit = process.waitFor()
|
||||||
|
StaticAnalysisRunResult(exit, outputDeferred.await())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}.getOrElse {
|
}.getOrElse {
|
||||||
process.destroyForcibly()
|
process.destroyForcibly()
|
||||||
val reason = if (it is TimeoutCancellationException) {
|
val reason = if (it is TimeoutCancellationException) {
|
||||||
"static analysis '$command' timed out after ${timeoutMs}ms"
|
"static analysis '$displayCommand' timed out after ${timeoutMs}ms"
|
||||||
} else {
|
} else {
|
||||||
it.message ?: "error running '$command'"
|
it.message ?: "error running '$displayCommand'"
|
||||||
}
|
}
|
||||||
StaticAnalysisRunResult(EXIT_NOT_RUN, reason)
|
StaticAnalysisRunResult(EXIT_NOT_RUN, reason)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val DEFAULT_TIMEOUT_MS = 300_000L
|
const val DEFAULT_TIMEOUT_MS = 300_000L
|
||||||
const val EXIT_NOT_RUN = -1
|
const val EXIT_NOT_RUN = -1
|
||||||
|
const val STATIC_FLOOR_COMMAND = "correx-static-floor"
|
||||||
val WHITESPACE = Regex("\\s+")
|
val WHITESPACE = Regex("\\s+")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -212,6 +212,7 @@ abstract class SessionOrchestrator(
|
|||||||
internal val workspacePolicy: WorkspacePolicy? = engines.workspacePolicy
|
internal val workspacePolicy: WorkspacePolicy? = engines.workspacePolicy
|
||||||
internal val worldProbe: WorldProbe = engines.worldProbe
|
internal val worldProbe: WorldProbe = engines.worldProbe
|
||||||
internal val staticAnalysisRunner: StaticAnalysisRunner? = engines.staticAnalysisRunner
|
internal val staticAnalysisRunner: StaticAnalysisRunner? = engines.staticAnalysisRunner
|
||||||
|
internal val lspDiagnosticsRunner: LspDiagnosticsRunner? = engines.lspDiagnosticsRunner
|
||||||
internal val contractAssertionEvaluator: ContractAssertionEvaluator? = engines.contractAssertionEvaluator
|
internal val contractAssertionEvaluator: ContractAssertionEvaluator? = engines.contractAssertionEvaluator
|
||||||
internal val planCompilationCheck: PlanCompilationCheck? = engines.planCompilationCheck
|
internal val planCompilationCheck: PlanCompilationCheck? = engines.planCompilationCheck
|
||||||
internal val semanticReviewer: SemanticReviewer? = engines.semanticReviewer
|
internal val semanticReviewer: SemanticReviewer? = engines.semanticReviewer
|
||||||
|
|||||||
+7
-4
@@ -76,7 +76,7 @@ internal suspend fun SessionOrchestrator.repairArtifact(
|
|||||||
if (eligible && bestCandidate != null && !isCancelled(sessionId)) {
|
if (eligible && bestCandidate != null && !isCancelled(sessionId)) {
|
||||||
emitArtifactRepairAttempted(sessionId, stageId, slot, unresolved.classification, "LLM")
|
emitArtifactRepairAttempted(sessionId, stageId, slot, unresolved.classification, "LLM")
|
||||||
val repaired = runArtifactRepairInference(
|
val repaired = runArtifactRepairInference(
|
||||||
sessionId, stageId, slot, bestCandidate, stageConfig, effectives, timeoutMs,
|
sessionId, stageId, slot, bestCandidate, unresolved.detail, stageConfig, effectives, timeoutMs,
|
||||||
)
|
)
|
||||||
val reRun = repaired?.let { artifactExtractionPipeline.run(it, schema) }
|
val reRun = repaired?.let { artifactExtractionPipeline.run(it, schema) }
|
||||||
if (reRun is ArtifactExtractionPipeline.ExtractionResult.Resolved) {
|
if (reRun is ArtifactExtractionPipeline.ExtractionResult.Resolved) {
|
||||||
@@ -115,15 +115,18 @@ internal suspend fun SessionOrchestrator.runArtifactRepairInference(
|
|||||||
stageId: StageId,
|
stageId: StageId,
|
||||||
slot: TypedArtifactSlot,
|
slot: TypedArtifactSlot,
|
||||||
bestCandidate: String,
|
bestCandidate: String,
|
||||||
|
validationError: String,
|
||||||
stageConfig: StageConfig,
|
stageConfig: StageConfig,
|
||||||
effectives: RunEffectives,
|
effectives: RunEffectives,
|
||||||
timeoutMs: Long,
|
timeoutMs: Long,
|
||||||
): String? {
|
): String? {
|
||||||
val schema = slot.kind.deriveJsonSchema()
|
val schema = slot.kind.deriveJsonSchema()
|
||||||
val schemaJson = Json.encodeToString(JsonSchema.serializer(), schema)
|
val schemaJson = Json.encodeToString(JsonSchema.serializer(), schema)
|
||||||
val prompt = "The previous output for the '${slot.name.value}' artifact was malformed and did not " +
|
val prompt = "The previous output for the '${slot.name.value}' artifact failed schema validation.\n\n" +
|
||||||
"match the required schema.\n\nMalformed output:\n$bestCandidate\n\nReturn ONLY a single JSON " +
|
"Validation error:\n$validationError\n\nMalformed output:\n$bestCandidate\n\nFix ONLY what the " +
|
||||||
"object matching this schema. Do not add fields, prose, or code fences.\nSchema: $schemaJson"
|
"validation error names — every field must sit at the level the schema defines; do not nest a " +
|
||||||
|
"top-level field inside another object. Return ONLY a single JSON object matching this schema. " +
|
||||||
|
"Do not add fields, prose, or code fences.\nSchema: $schemaJson"
|
||||||
val entry = ContextEntry(
|
val entry = ContextEntry(
|
||||||
id = ContextEntryId(UUID.randomUUID().toString()),
|
id = ContextEntryId(UUID.randomUUID().toString()),
|
||||||
layer = ContextLayer.L2,
|
layer = ContextLayer.L2,
|
||||||
|
|||||||
+7
-3
@@ -190,9 +190,13 @@ internal suspend fun SessionOrchestrator.buildClarificationAnswerEntries(session
|
|||||||
/**
|
/**
|
||||||
* If [stageId] just produced an LLM artifact carrying a non-empty `questions` array, park the
|
* If [stageId] just produced an LLM artifact carrying a non-empty `questions` array, park the
|
||||||
* run: record the questions (invariant #9 — observed at parse time), await the operator's
|
* run: record the questions (invariant #9 — observed at parse time), await the operator's
|
||||||
* answers, record them, and return true so the caller re-runs the stage with the answers in
|
* answers, and record them. The caller then ADVANCES to the next stage — the stage is not
|
||||||
* context. Bounded by [MAX_CLARIFICATION_ROUNDS] so a stage that keeps re-asking eventually
|
* re-run. The answers are session-wide events, injected into every later stage's L0 context
|
||||||
* proceeds. Returns false when there are no questions or the round budget is spent.
|
* (see [buildClarificationAnswerEntries]), so the next stage acts on them directly; re-running
|
||||||
|
* the questioning stage only restarts inference with no prior reasoning and makes it re-explore.
|
||||||
|
* Bounded by [OrchestrationTuning.maxClarificationRounds] so retries can't re-park unboundedly.
|
||||||
|
* Returns true when it parked and collected answers, false when there are no questions or the
|
||||||
|
* round budget is spent.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
internal suspend fun SessionOrchestrator.requestClarificationIfNeeded(
|
internal suspend fun SessionOrchestrator.requestClarificationIfNeeded(
|
||||||
|
|||||||
+21
-4
@@ -374,12 +374,29 @@ internal suspend fun SessionOrchestrator.executeStage(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// emit_artifact is a meta-tool: the LLM calls it with the artifact's fields as arguments.
|
// emit_artifact is a meta-tool: the LLM calls it with the artifact's fields as arguments.
|
||||||
// Capture those arguments as the artifact content and exit the loop straight to validation
|
// Validate those arguments INLINE, like any other tool call: a valid artifact captures and
|
||||||
// (the post-loop capture honours this override instead of the empty assistant text).
|
// exits the loop; an invalid one is handed straight back into the SAME conversation as a
|
||||||
|
// tool-result error, so the model fixes it with its full context (and CoT) intact. Breaking
|
||||||
|
// the loop on a bad artifact would instead drop into a cold repair/retry that re-explores
|
||||||
|
// from scratch and tends to re-make the identical schema mistake (2026-07-18).
|
||||||
val emitCall = inferenceResult.response.toolCalls.firstOrNull { it.function.name == EMIT_ARTIFACT_TOOL }
|
val emitCall = inferenceResult.response.toolCalls.firstOrNull { it.function.name == EMIT_ARTIFACT_TOOL }
|
||||||
if (emitCall != null && llmEmittedSlots.isNotEmpty()) {
|
if (emitCall != null && llmEmittedSlots.isNotEmpty()) {
|
||||||
llmArtifactOverride = emitCall.function.arguments
|
val emitSlot = llmEmittedSlots.first()
|
||||||
break
|
when (val res = artifactExtractionPipeline.run(emitCall.function.arguments, emitSlot.kind.deriveJsonSchema())) {
|
||||||
|
is ArtifactExtractionPipeline.ExtractionResult.Resolved -> {
|
||||||
|
llmArtifactOverride = res.canonicalJson.toString()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
is ArtifactExtractionPipeline.ExtractionResult.Unresolved -> {
|
||||||
|
inferenceResult = pushBack(
|
||||||
|
"ERROR: your emit_artifact call for '${emitSlot.name.value}' did not match the " +
|
||||||
|
"schema: ${res.detail}. Call emit_artifact again with only that corrected. Every " +
|
||||||
|
"field must sit at the level the schema defines — do not nest a top-level field " +
|
||||||
|
"(e.g. ready, questions) inside another object.",
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// stage_complete is a meta-tool: the LLM calls it to signal the stage's goal is met.
|
// stage_complete is a meta-tool: the LLM calls it to signal the stage's goal is met.
|
||||||
// Skip tool dispatch — the loop exits and normal validation/transition follows
|
// Skip tool dispatch — the loop exits and normal validation/transition follows
|
||||||
|
|||||||
+1
@@ -199,6 +199,7 @@ internal suspend fun SessionOrchestrator.runPostStageGates(
|
|||||||
{ runContractGate(sessionId, stageId, stageConfig, effectives) },
|
{ runContractGate(sessionId, stageId, stageConfig, effectives) },
|
||||||
{ runPlanCompileGate(sessionId, stageId, stageConfig) },
|
{ runPlanCompileGate(sessionId, stageId, stageConfig) },
|
||||||
{ runStaticAnalysis(sessionId, stageId, stageConfig, effectives) },
|
{ runStaticAnalysis(sessionId, stageId, stageConfig, effectives) },
|
||||||
|
{ runLspDiagnostics(sessionId, stageId, stageConfig, effectives) },
|
||||||
{ runExecutionGate(sessionId, stageId, stageConfig, effectives, profileCommands) },
|
{ runExecutionGate(sessionId, stageId, stageConfig, effectives, profileCommands) },
|
||||||
{ runReviewGate(sessionId, stageId, stageConfig, effectives) },
|
{ runReviewGate(sessionId, stageId, stageConfig, effectives) },
|
||||||
)
|
)
|
||||||
|
|||||||
+56
@@ -5,6 +5,7 @@ import com.correx.core.events.events.ReviewVerdict
|
|||||||
import com.correx.core.events.events.ArtifactCreatedEvent
|
import com.correx.core.events.events.ArtifactCreatedEvent
|
||||||
import com.correx.core.events.events.StaticAnalysisCompletedEvent
|
import com.correx.core.events.events.StaticAnalysisCompletedEvent
|
||||||
import com.correx.core.events.events.StaticAnalysisFinding
|
import com.correx.core.events.events.StaticAnalysisFinding
|
||||||
|
import com.correx.core.events.events.LspDiagnosticsCompletedEvent
|
||||||
import com.correx.core.events.events.ArtifactValidatedEvent
|
import com.correx.core.events.events.ArtifactValidatedEvent
|
||||||
import com.correx.core.events.events.ArtifactValidatingEvent
|
import com.correx.core.events.events.ArtifactValidatingEvent
|
||||||
import com.correx.core.events.types.SessionId
|
import com.correx.core.events.types.SessionId
|
||||||
@@ -66,6 +67,55 @@ internal suspend fun SessionOrchestrator.runStaticAnalysis(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("ReturnCount")
|
||||||
|
internal suspend fun SessionOrchestrator.runLspDiagnostics(
|
||||||
|
sessionId: SessionId,
|
||||||
|
stageId: StageId,
|
||||||
|
stageConfig: StageConfig,
|
||||||
|
effectives: RunEffectives,
|
||||||
|
): StageExecutionResult {
|
||||||
|
val runner = lspDiagnosticsRunner ?: return StageExecutionResult.Success(emptyList())
|
||||||
|
val workspaceRoot = effectives.policy?.workspaceRoot ?: return StageExecutionResult.Success(emptyList())
|
||||||
|
val stagePaths = stageWrittenPaths(sessionId, stageId)
|
||||||
|
val paths = if (stagePaths.isNotEmpty()) {
|
||||||
|
stagePaths
|
||||||
|
} else if (stageConfig.autoBuildGate) {
|
||||||
|
sessionWrittenPaths(sessionId)
|
||||||
|
} else {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
if (paths.isEmpty()) return StageExecutionResult.Success(emptyList())
|
||||||
|
|
||||||
|
val result = runner.pull(LspDiagnosticsRequest(workspaceRoot, paths))
|
||||||
|
val plannedButUnwritten = stageConfig.writeManifest.filterNot { it in paths }
|
||||||
|
val diagnostics = result.diagnostics.filter { diagnostic ->
|
||||||
|
diagnostic.path in paths && plannedButUnwritten.none { planned ->
|
||||||
|
diagnostic.message.contains(planned.substringAfterLast('/'), ignoreCase = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
emit(
|
||||||
|
sessionId,
|
||||||
|
LspDiagnosticsCompletedEvent(sessionId, stageId, result.server, diagnostics, result.skippedReason),
|
||||||
|
)
|
||||||
|
val errors = diagnostics.filter { it.severity.equals("error", ignoreCase = true) }
|
||||||
|
if (errors.isEmpty()) return StageExecutionResult.Success(emptyList())
|
||||||
|
val detail = errors.joinToString("\n") {
|
||||||
|
"- ${it.path}:${it.line + 1}:${it.character + 1} ${it.code.orEmpty()} ${it.message}".trim()
|
||||||
|
}
|
||||||
|
return StageExecutionResult.Failure(
|
||||||
|
"stage ${stageId.value} has LSP diagnostics in files it wrote. Fix these before proceeding:\n$detail",
|
||||||
|
retryable = true,
|
||||||
|
gate = "lsp_diagnostics",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun SessionOrchestrator.sessionWrittenPaths(sessionId: SessionId): List<String> =
|
||||||
|
eventStore.read(sessionId)
|
||||||
|
.mapNotNull { it.payload as? com.correx.core.events.events.FileWrittenEvent }
|
||||||
|
.filter { it.postImageHash != null }
|
||||||
|
.map { it.path }
|
||||||
|
.distinct()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gate 4 — execution (staged-verification §1/§2). A stage's [StageConfig.buildExpectation]
|
* Gate 4 — execution (staged-verification §1/§2). A stage's [StageConfig.buildExpectation]
|
||||||
* (none/module/project/tests) resolves to the bound project profile's typecheck/build/test
|
* (none/module/project/tests) resolves to the bound project profile's typecheck/build/test
|
||||||
@@ -76,6 +126,7 @@ internal suspend fun SessionOrchestrator.runStaticAnalysis(
|
|||||||
* [StaticAnalysisCompletedEvent], invariant #9, so replay never re-runs it).
|
* [StaticAnalysisCompletedEvent], invariant #9, so replay never re-runs it).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@Suppress("ReturnCount")
|
||||||
internal suspend fun SessionOrchestrator.runExecutionGate(
|
internal suspend fun SessionOrchestrator.runExecutionGate(
|
||||||
sessionId: SessionId,
|
sessionId: SessionId,
|
||||||
stageId: StageId,
|
stageId: StageId,
|
||||||
@@ -93,6 +144,11 @@ internal suspend fun SessionOrchestrator.runExecutionGate(
|
|||||||
stageConfig.autoBuildGate && sessionProducedBuildTarget(sessionId) -> BuildExpectation.PROJECT
|
stageConfig.autoBuildGate && sessionProducedBuildTarget(sessionId) -> BuildExpectation.PROJECT
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
|
// LSP pull diagnostics are the compiler-front-end/typecheck gate. Keep PROJECT bundlers and
|
||||||
|
// TESTS as real commands, but do not invoke the profile's flat `typecheck` alias as well.
|
||||||
|
if (expectation == BuildExpectation.MODULE && lspDiagnosticsRunner != null) {
|
||||||
|
return StageExecutionResult.Success(emptyList())
|
||||||
|
}
|
||||||
val alias = expectation?.commandAlias ?: return StageExecutionResult.Success(emptyList())
|
val alias = expectation?.commandAlias ?: return StageExecutionResult.Success(emptyList())
|
||||||
val runner = staticAnalysisRunner
|
val runner = staticAnalysisRunner
|
||||||
val workspaceRoot = effectives.policy?.workspaceRoot
|
val workspaceRoot = effectives.policy?.workspaceRoot
|
||||||
|
|||||||
+17
@@ -51,4 +51,21 @@ class ProcessStaticAnalysisRunnerTest {
|
|||||||
val result = runner.run(dir, " ")
|
val result = runner.run(dir, " ")
|
||||||
assertTrue(result.exitCode != 0, "exit: ${result.exitCode}")
|
assertTrue(result.exitCode != 0, "exit: ${result.exitCode}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `static floor skips an unsupported filetype without failing`() = runBlocking {
|
||||||
|
val dir = createTempDirectory("sa-skip")
|
||||||
|
dir.resolve("Main.kt").writeText("fun main() = Unit")
|
||||||
|
val result = runner.run(dir, "correx-static-floor -- Main.kt")
|
||||||
|
assertEquals(0, result.exitCode)
|
||||||
|
assertTrue(result.output.contains("skipped"), "output: ${result.output}")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `static floor checks javascript syntax without project dependencies`() = runBlocking {
|
||||||
|
val dir = createTempDirectory("sa-js")
|
||||||
|
dir.resolve("broken.js").writeText("const = ;")
|
||||||
|
val result = runner.run(dir, "correx-static-floor -- broken.js")
|
||||||
|
assertTrue(result.exitCode != 0, "exit: ${result.exitCode}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@ Maintained alongside the features they describe. Any agent shipping a significan
|
|||||||
- `decisions/` — ADRs (adr-NNNN-*.md). Append-only. Never delete or retroactively alter a decided ADR; write a superseding one instead.
|
- `decisions/` — ADRs (adr-NNNN-*.md). Append-only. Never delete or retroactively alter a decided ADR; write a superseding one instead.
|
||||||
- `qa/` — QA run plans (QA-*.md). Each maps to a shipped feature or an explicitly pending controlled audition. `TEMPLATE.md` is the canonical shape. `ENV.md` describes the required live environment. `README.md` explains the QA process.
|
- `qa/` — QA run plans (QA-*.md). Each maps to a shipped feature or an explicitly pending controlled audition. `TEMPLATE.md` is the canonical shape. `ENV.md` describes the required live environment. `README.md` explains the QA process.
|
||||||
- `specs/` — feature specs by date-slug. Inputs for planned or in-flight work.
|
- `specs/` — feature specs by date-slug. Inputs for planned or in-flight work.
|
||||||
- `schemas/` — canonical JSON schemas for structured outputs (analysis, brief_echo, design, execution_plan, impl_plan). Shared across the router and validator.
|
- `schemas/` — canonical JSON schemas for structured outputs (analysis, discovery brief, DoD, brief_echo, design, execution_plan, impl_plan). Shared across the router and validator.
|
||||||
- `epics/`, `modules/`, `diagrams/`, `design/`, `reviews/`, `visual/` — supporting reference material.
|
- `epics/`, `modules/`, `diagrams/`, `design/`, `reviews/`, `visual/` — supporting reference material.
|
||||||
|
|
||||||
**⚠️ STALE / DO NOT TRUST FOR STATUS:**
|
**⚠️ STALE / DO NOT TRUST FOR STATUS:**
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
# Correx — Catch-up for a ~2-month-old memory
|
||||||
|
|
||||||
|
**If your last memory of Correx is the epic-13/epic-14 era (mid-May 2026), read this.**
|
||||||
|
|
||||||
|
Back then the project was a *skeleton*: epics 0–14 had landed the module structure
|
||||||
|
(events → sessions → transitions → validation → approvals → artifacts → context →
|
||||||
|
tools → inference → kernel → infra → interfaces → router). It compiled and replayed,
|
||||||
|
but the orchestration loop was thin and mostly unproven against real model runs.
|
||||||
|
|
||||||
|
Two months and ~250 feature commits later, the architecture is the *same* (all 9 Hard
|
||||||
|
Invariants still hold; the event log is still the only source of truth), but nearly the
|
||||||
|
entire *behavioral* layer — how a session actually drives a model through real work —
|
||||||
|
was built and hardened by live QA. This document groups the major additions. It is a
|
||||||
|
map, not a changelog; the dated files in `docs/plans/` are the real changelog.
|
||||||
|
|
||||||
|
Work is no longer tracked as "epics." It's tracked as **plans** (`docs/plans/`) and a
|
||||||
|
**Vikunja backlog** (project_id 4).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The single biggest correction: the TUI is now Go
|
||||||
|
|
||||||
|
The Kotlin TUI (Mosaic, then tamboui) was **retired**. The interactive terminal UI is
|
||||||
|
now a **Go / Bubble Tea** app at `apps/tui-go` — a WS client of the server. `apps/cli`
|
||||||
|
(Clikt, Kotlin) and `apps/server` (Ktor) are the only Gradle app modules; the old
|
||||||
|
`apps/desktop` / `apps/worker` stubs were deleted. If you remember tamboui, forget it.
|
||||||
|
|
||||||
|
## New whole subsystems (did not meaningfully exist at epic-14)
|
||||||
|
|
||||||
|
**Native task tracking (`core:tasks`).** A dependency-aware work graph: `task_decompose`
|
||||||
|
splits a goal into a DEPENDS_ON graph in one approval; tasks have claim gates
|
||||||
|
(dependency-hard-gate, cite-before-claim, duplicate-title guard), per-task history,
|
||||||
|
markdown export, a full REST surface, a `correx task` CLI, a TUI task board with
|
||||||
|
readiness, and **git-driven status** (a commit mention → IN_PROGRESS, a closing keyword
|
||||||
|
→ DONE, idempotent). The execution loop is now *tasked* — it replaced the old linear
|
||||||
|
role_pipeline.
|
||||||
|
|
||||||
|
**Plane-2 tool-call intent validation (`core:toolintent`).** An anti-hallucination /
|
||||||
|
safety layer that assesses every tool call *before* it runs and records
|
||||||
|
`ToolCallAssessedEvent`: path-containment, read-before-write, reference-must-exist,
|
||||||
|
write-scope adherence, stale-write, exec-interpreter and network-host rules, and a
|
||||||
|
per-session egress allowlist. This is the layer that stops an agent citing files it
|
||||||
|
never read or writing outside its declared scope.
|
||||||
|
|
||||||
|
**Compression pipeline (`infrastructure:compression`).** A staged, replay-safe context
|
||||||
|
compressor: level policy → fact sheet + strict allocation → tier summarizer → token
|
||||||
|
pruning + embedding relevance + ToMe merge → static-doc pruning (CLAUDE.md/AGENTS.md).
|
||||||
|
Distinct from the required/optional *bucket* rework in `core:context`.
|
||||||
|
|
||||||
|
**Staged verification & review gates.** Stage output passes a gate ladder instead of
|
||||||
|
being trusted: build gate, plan-compile gate, contract/execution gate, and a
|
||||||
|
semantic-review gate (`core:critique`, with structured `CritiqueFinding` + per-model
|
||||||
|
calibration). Per-gate retry budgets with progress-aware charging and hybrid salvage;
|
||||||
|
static-first reviewer framing; capability-gap reflection; brief echo-back gate.
|
||||||
|
|
||||||
|
**Research workflow.** `WebSearchTool` (local SearXNG), `WebFetchTool` (bounded fetch),
|
||||||
|
deterministic HTML→markdown extraction, a research workflow graph, batch source-fetch
|
||||||
|
approval, and dedicated `SourceFetched` / `LowQualityExtraction` events. On by default.
|
||||||
|
|
||||||
|
**Cross-session repo memory (L3 + repo-map + project profile).** `RepoMapIndexer`
|
||||||
|
(symbols for Kotlin, GDScript, C#/Godot-Mono, markdown headings), `RepoKnowledgeRetriever`
|
||||||
|
with recency fallback, L3 ANN retrieval (`in_memory` or `turbovec` sidecar) recorded as
|
||||||
|
environment observations, `ProjectMemoryService`/`Distiller`, and `ProjectProfile`
|
||||||
|
(per-repo standing context at `.correx/project.toml`) injected as L0. Note: this is
|
||||||
|
*repo-scoped* memory — the "cross-session memory" anti-feature (conversational recall)
|
||||||
|
is still deliberately out.
|
||||||
|
|
||||||
|
**Git run-branch transport (the "Gitea transport") — SHIPPED.** The server owns a Git
|
||||||
|
checkout; each run pushes to a run branch (`GitRunBranchTransport`, `RunBranchPushedEvent`);
|
||||||
|
`GitTaskSync`/`GitCommandCommitReader` drive task status from commits. "gitea" is just
|
||||||
|
the configured remote name; the transport is plain Git.
|
||||||
|
|
||||||
|
**Observability & replay tooling (Epic 15, largely shipped).** Event-stream inspector
|
||||||
|
(`correx events`, `/sessions/{id}/events`), session replay + determinism digest,
|
||||||
|
causation-graph DOT export, metrics as a replayable projection (`correx stats`),
|
||||||
|
event-sourced health checks (EventStore latency, llama-server liveness, disk watermark,
|
||||||
|
ROCm/VRAM probe), and correlation-structured MDC logging.
|
||||||
|
|
||||||
|
**Model lifecycle management (`[[models]]`).** Correx spawns/owns the llama-server
|
||||||
|
process: per-stage model selection, manual swap + pin, resource telemetry, VRAM gauge.
|
||||||
|
(The autonomous *residency scheduler* is still unbuilt — see below.)
|
||||||
|
|
||||||
|
**Workspace scoping & undo.** `WorkspaceResolver` trust pipeline + `allowed_workspace_roots`,
|
||||||
|
per-session workspace-scoped tools/policy, client→server workspace handshake, crash-safe
|
||||||
|
atomic file writes with pre/post-image captured to CAS (`FileWrittenEvent`), and a
|
||||||
|
`FileMutationReverser` undo primitive (server endpoint + CLI). Agents can also READ
|
||||||
|
outside `workspace_root` via an operator approval prompt (writes stay jailed).
|
||||||
|
|
||||||
|
**Approvals & grants.** Cross-session grant scopes (PROJECT/GLOBAL) + revoke,
|
||||||
|
grant-aware approval engine, risk rationale surfaced to the operator; approval gates now
|
||||||
|
block indefinitely instead of auto-rejecting on timeout, and steering actually affects
|
||||||
|
the run.
|
||||||
|
|
||||||
|
**Router interaction layer.** Beyond CHAT+STEERING: grounded system prompts, triage
|
||||||
|
directive (no bare refusals), structured **workflow proposals** from triage, a
|
||||||
|
**clarification-question loop** (analyst asks, operator answers, producer-exit rehydrate
|
||||||
|
on reconnect), **rubber-duck idea board** (capture/feed/promote to project profile), and
|
||||||
|
grounded **narration** (pinned narration model, latency/token metrics surfaced).
|
||||||
|
|
||||||
|
**Freestyle / execution-plan engine.** Two-phase planning→execution driver,
|
||||||
|
`ExecutionPlanCompiler` + `ExecutionPlanLockedEvent`, post-plan operator approval gate,
|
||||||
|
freestyle graph re-routing (deterministic override), soft-lock steering preemption,
|
||||||
|
plan grounding + positive-pattern mining + a stage-prompt audition rig.
|
||||||
|
|
||||||
|
**Decision journal (`core:journal`).** Decision-journal projection injected into stage
|
||||||
|
context, salience-aware compaction (`JournalCompactionService`, CAS-rendered).
|
||||||
|
|
||||||
|
**Personalization (`core:config`).** `OperatorProfile` + `ProfileLoader` bound at start
|
||||||
|
and surfaced into L0; propose-only `ProfileAdaptationService`.
|
||||||
|
|
||||||
|
**Native MCP host.** The server mounts external stdio MCP servers as first-class Correx
|
||||||
|
tools (inherit tier / receipt / replay). `codebase-memory-mcp` is installed and granted
|
||||||
|
per code-intel stage.
|
||||||
|
|
||||||
|
**Recovery loop.** Failed write-less stages route to recovery instead of futile retry;
|
||||||
|
tier-2 intent-holder arbiter; remaining-delta pinning; failure-ticket routing +
|
||||||
|
review-gate RECOVER + return-to-sender; structured package-404 recovery evidence;
|
||||||
|
stage-global repeated-failure loop breaker.
|
||||||
|
|
||||||
|
**Godot / game-dev support.** GDScript + C#/Godot-Mono symbol extraction in the repo
|
||||||
|
map, and a structural `.tscn`/`.tres` scene validator.
|
||||||
|
|
||||||
|
## Refactors & hardening you'll notice
|
||||||
|
|
||||||
|
- `DefaultSessionOrchestrator` god-class decomposed into per-concern extensions;
|
||||||
|
`DomainEventMapper`'s god-`when` split per-domain. Detekt baseline ratcheted 120 → 90.
|
||||||
|
- Persistence hardened: sequences assigned atomically inside INSERT, reads serialized
|
||||||
|
with the append transaction, slow WS clients no longer stall the kernel.
|
||||||
|
- Tool results bounded + framed, full output spilled to CAS with a `tool_output`
|
||||||
|
retrieval tool; `glob`/`grep` search tools; web_fetch SSRF fix; interruptible shell
|
||||||
|
timeouts with process-tree kill; shell allowlist validates every command position.
|
||||||
|
- Config: orchestration loop/threshold/budget constants moved to `[orchestration]`;
|
||||||
|
operator-tunable sampling knobs (top_k/min_p/repeat_penalty) per stage.
|
||||||
|
|
||||||
|
## Still planned / deliberately NOT built (don't assume these exist)
|
||||||
|
|
||||||
|
- Full **remote always-on / thin-client** deployment topology. Its Git *transport*
|
||||||
|
shipped (above); the hosting topology itself is not yet stood up.
|
||||||
|
- Full router **context isolation** (persistent conversation history, session-scoped
|
||||||
|
boundaries). CHAT+STEERING works; the isolation layer is deferred.
|
||||||
|
- Autonomous GPU **residency scheduling** (lifecycle *is* built; idle-eviction policy
|
||||||
|
is not).
|
||||||
|
- Still explicitly out: parallel agent execution, conversational cross-session memory,
|
||||||
|
three-critic ensemble, fine-tuning, streaming inference.
|
||||||
|
|
||||||
|
## Where to look now
|
||||||
|
|
||||||
|
- `CLAUDE.md` — current session guide, Module Map, invariants.
|
||||||
|
- `docs/plans/` — the real changelog of intent since epic-14 (dated files).
|
||||||
|
- Vikunja project 4 — live backlog. `scripts/ctx.py <query>` — ranked file/symbol lookup.
|
||||||
@@ -135,6 +135,10 @@ backend = "in_memory" # or "turbovec"
|
|||||||
# schema_path = "schemas/review_report.json"
|
# schema_path = "schemas/review_report.json"
|
||||||
# llm_emitted = true
|
# llm_emitted = true
|
||||||
#
|
#
|
||||||
|
# Review loops escalate to the synthesized recovery stage after this many review→rework cycles.
|
||||||
|
# [orchestration]
|
||||||
|
# review_loop_max_cycles = 3
|
||||||
|
#
|
||||||
# The bundled examples/workflows/review_loop.toml uses this kind: the reviewer emits a
|
# The bundled examples/workflows/review_loop.toml uses this kind: the reviewer emits a
|
||||||
# review_report whose `verdict` field gates the implementer↔reviewer loop via
|
# review_report whose `verdict` field gates the implementer↔reviewer loop via
|
||||||
# artifact_field_equals transitions. See docs/schemas/review_report.json for the schema.
|
# artifact_field_equals transitions. See docs/schemas/review_report.json for the schema.
|
||||||
@@ -143,3 +147,8 @@ backend = "in_memory" # or "turbovec"
|
|||||||
id = "execution_plan"
|
id = "execution_plan"
|
||||||
schema_path = "schemas/execution_plan.json"
|
schema_path = "schemas/execution_plan.json"
|
||||||
llm_emitted = true
|
llm_emitted = true
|
||||||
|
|
||||||
|
[[artifacts]]
|
||||||
|
id = "dod"
|
||||||
|
schema_path = "schemas/dod.json"
|
||||||
|
llm_emitted = true
|
||||||
|
|||||||
@@ -1,6 +1,20 @@
|
|||||||
{
|
{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"brief": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"what": { "type": "string" },
|
||||||
|
"why": { "type": "string" },
|
||||||
|
"who": { "type": "array", "items": { "type": "string" } },
|
||||||
|
"scope": { "type": "array", "items": { "type": "string" } },
|
||||||
|
"non_goals": { "type": "array", "items": { "type": "string" } },
|
||||||
|
"constraints": { "type": "array", "items": { "type": "string" } },
|
||||||
|
"assumptions": { "type": "array", "items": { "type": "string" } }
|
||||||
|
},
|
||||||
|
"required": ["what", "why", "who", "scope", "non_goals", "constraints", "assumptions"],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
"ready": {
|
"ready": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "true when the request is clear and grounded enough to plan; false when you are raising questions"
|
"description": "true when the request is clear and grounded enough to plan; false when you are raising questions"
|
||||||
@@ -33,6 +47,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["ready", "questions"],
|
"required": ["brief", "ready", "questions"],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"summary": { "type": "string" },
|
||||||
|
"criteria": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "string" },
|
||||||
|
"statement": { "type": "string" },
|
||||||
|
"part": { "type": "string" },
|
||||||
|
"verified_by": { "type": "string", "description": "one of: gate, reviewer" }
|
||||||
|
},
|
||||||
|
"required": ["id", "statement", "part", "verified_by"],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"out_of_scope": { "type": "array", "items": { "type": "string" } }
|
||||||
|
},
|
||||||
|
"required": ["summary", "criteria", "out_of_scope"],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
@@ -14,6 +14,10 @@
|
|||||||
"produces": { "type": "string" },
|
"produces": { "type": "string" },
|
||||||
"kind": { "type": "string" },
|
"kind": { "type": "string" },
|
||||||
"tools": { "type": "array", "items": { "type": "string" } }
|
"tools": { "type": "array", "items": { "type": "string" } }
|
||||||
|
,"writes": { "type": "array", "items": { "type": "string" } }
|
||||||
|
,"touches": { "type": "array", "items": { "type": "string" } }
|
||||||
|
,"build_expectation": { "type": "string", "description": "one of: none, module, project, tests" }
|
||||||
|
,"semantic_review": { "type": "boolean" }
|
||||||
},
|
},
|
||||||
"required": ["id", "prompt", "produces"]
|
"required": ["id", "prompt", "produces"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ Each TOML file in `workflows/` is a valid workflow loadable by the server. Keep
|
|||||||
- Add a new workflow example when a major new workflow type ships.
|
- Add a new workflow example when a major new workflow type ships.
|
||||||
- Prompts referenced by TOML files go in `workflows/prompts/`.
|
- Prompts referenced by TOML files go in `workflows/prompts/`.
|
||||||
- Freestyle architect prompts specify stage constraints, boundaries, and verification goals; they do not prescribe an exact resulting file list when the authoritative intent leaves implementation details open.
|
- Freestyle architect prompts specify stage constraints, boundaries, and verification goals; they do not prescribe an exact resulting file list when the authoritative intent leaves implementation details open.
|
||||||
|
- Freestyle discovery emits a structured comprehension brief; the analyst emits the addressable `dod` artifact used as the fixed implementation/review rubric.
|
||||||
- Do not add configs/plugins/stages stubs speculatively — populate when there is real content.
|
- Do not add configs/plugins/stages stubs speculatively — populate when there is real content.
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ id = "analysis"
|
|||||||
schema_path = "schemas/analysis.json"
|
schema_path = "schemas/analysis.json"
|
||||||
llm_emitted = true
|
llm_emitted = true
|
||||||
|
|
||||||
|
[[artifacts]]
|
||||||
|
id = "dod"
|
||||||
|
schema_path = "schemas/dod.json"
|
||||||
|
llm_emitted = true
|
||||||
|
|
||||||
[[artifacts]]
|
[[artifacts]]
|
||||||
id = "design"
|
id = "design"
|
||||||
schema_path = "schemas/design.json"
|
schema_path = "schemas/design.json"
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ allowed_tools = ["file_read", "list_dir", "shell"]
|
|||||||
token_budget = 16384
|
token_budget = 16384
|
||||||
max_retries = 2
|
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 and the fixed definition of done:
|
||||||
# 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)
|
||||||
# opens a single task; task_decompose (T2, one approval for the whole graph) splits a goal with
|
# opens a single task; task_decompose (T2, one approval for the whole graph) splits a goal with
|
||||||
# dependency seams into parent + DEPENDS_ON-linked children. Either way the analysis names the task
|
# dependency seams into parent + DEPENDS_ON-linked children. Either way the analysis names the task
|
||||||
@@ -20,7 +20,8 @@ max_retries = 2
|
|||||||
[[stages]]
|
[[stages]]
|
||||||
id = "analyst"
|
id = "analyst"
|
||||||
prompt = "prompts/analyst_freestyle.md"
|
prompt = "prompts/analyst_freestyle.md"
|
||||||
produces = [{ name = "analysis", kind = "analysis" }]
|
needs = ["discovery"]
|
||||||
|
produces = [{ name = "dod", kind = "dod" }]
|
||||||
allowed_tools = ["file_read", "list_dir", "shell", "task_search", "task_context", "task_create", "task_decompose"]
|
allowed_tools = ["file_read", "list_dir", "shell", "task_search", "task_context", "task_create", "task_decompose"]
|
||||||
token_budget = 16384
|
token_budget = 16384
|
||||||
max_retries = 2
|
max_retries = 2
|
||||||
@@ -30,7 +31,7 @@ id = "architect"
|
|||||||
requires_approval = true
|
requires_approval = true
|
||||||
inject_artifact_kinds = true
|
inject_artifact_kinds = true
|
||||||
prompt = "prompts/architect_freestyle.md"
|
prompt = "prompts/architect_freestyle.md"
|
||||||
needs = ["analysis"]
|
needs = ["dod"]
|
||||||
produces = [{ name = "execution_plan", kind = "execution_plan" }]
|
produces = [{ name = "execution_plan", kind = "execution_plan" }]
|
||||||
token_budget = 16384
|
token_budget = 16384
|
||||||
max_retries = 2
|
max_retries = 2
|
||||||
@@ -47,7 +48,7 @@ id = "analyst-to-architect"
|
|||||||
from = "analyst"
|
from = "analyst"
|
||||||
to = "architect"
|
to = "architect"
|
||||||
condition_type = "artifact_validated"
|
condition_type = "artifact_validated"
|
||||||
condition_artifact_id = "analysis"
|
condition_artifact_id = "dod"
|
||||||
|
|
||||||
[[transitions]]
|
[[transitions]]
|
||||||
id = "architect-to-done"
|
id = "architect-to-done"
|
||||||
|
|||||||
@@ -1,33 +1,35 @@
|
|||||||
You are the **Analyst** in freestyle mode. Understand the user's goal (in the decision history
|
You are the **Analyst** in freestyle mode. Consume the structured discovery brief and operator
|
||||||
above) and the code it touches. Read-only: `file_read` (also lists a directory's entries when
|
answers in context, inspect the relevant code, and turn the settled request into one fixed,
|
||||||
given a directory path), `ls`, `grep`, `cat`, `find`.
|
structured definition of done. Read-only tools: `file_read`, `list_dir`, `shell`, `task_search`,
|
||||||
|
and `task_context`.
|
||||||
|
|
||||||
Before deriving requirements, check for existing work: `task_search` for related, duplicate, or
|
Before deriving criteria, check for existing work with `task_search` and load named work with
|
||||||
blocking tasks and `task_context` to load any the goal names. Fold what you find into the
|
`task_context`. Create or decompose a task only when needed by the existing task policy; include
|
||||||
analysis rather than re-deriving it; flag a duplicate instead of restating it.
|
the single task this run owns in the DoD summary or criterion part so execution can thread it.
|
||||||
|
|
||||||
Then frame the work as a task (per the task policy):
|
Emit the `dod` artifact once. Its criteria are the complete acceptance contract for this run:
|
||||||
- If a task already covers this work, name its id (e.g. `auth-142`) in the analysis.
|
|
||||||
- If the goal is a single coherent unit one run can carry to review, `task_create` one and name its
|
|
||||||
id.
|
|
||||||
- If the goal has **dependency seams** (a thing that must land before another) or **independent
|
|
||||||
review/handoff points** (a piece worth shipping or reviewing on its own), `task_decompose` it into
|
|
||||||
a parent epic + `DEPENDS_ON`-linked children — one approval for the whole graph. A session works
|
|
||||||
one task at a time, so the children are claimed by *later* runs as they unblock; don't over-split.
|
|
||||||
- After decomposing, **name in the analysis the single task this run will work** — the one already
|
|
||||||
ready (no unmet dependency, e.g. the scaffold). Leave the blocked siblings for future runs.
|
|
||||||
|
|
||||||
Either way later stages thread the named task through the plan; the rest wait to be claimed.
|
- Give every criterion a stable id (`c1`, `c2`, …), a checkable statement, and its feature area.
|
||||||
|
- Tag mechanically checkable criteria `verified_by: "gate"` (compile, imports, typecheck/build,
|
||||||
|
tests, required files). The reviewer must not adjudicate these.
|
||||||
|
- Tag semantic or UX criteria `verified_by: "reviewer"`.
|
||||||
|
- Copy discovery `brief.non_goals` into `out_of_scope`; this is a hard review boundary.
|
||||||
|
- Cover the entire in-scope brief now. Later stages may not silently add criteria.
|
||||||
|
|
||||||
Produce the `analysis` artifact by calling the **`emit_artifact`** tool with these fields:
|
Call `emit_artifact` with a JSON object matching this shape:
|
||||||
- `summary`: the goal in your own words.
|
`{"summary": string, "criteria": [{"id": string, "statement": string, "part": string,
|
||||||
- `requirements`: concrete, checkable requirements, one per line.
|
"verified_by": "gate" | "reviewer"}], "out_of_scope": [string]}`.
|
||||||
- `affected_areas`: files/modules likely involved, one per line.
|
|
||||||
|
|
||||||
Call `emit_artifact` once you have read enough — do not write the JSON as a plain message.
|
Example:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"summary": "Deliver the bounded validation gate for task gate-42.",
|
||||||
|
"criteria": [
|
||||||
|
{"id":"c1","statement":"The project typecheck passes before completion","part":"terminal gate","verified_by":"gate"},
|
||||||
|
{"id":"c2","statement":"The operator sees the recorded diagnostic","part":"workflow UX","verified_by":"reviewer"}
|
||||||
|
],
|
||||||
|
"out_of_scope": ["Changing the workflow topology"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
Always produce the analysis — this is your single exit. Open questions and operator forks are the
|
Do not ask questions; discovery owns clarification. Do not design or implement.
|
||||||
**Discovery** stage's job, and it has already run before you: any ambiguity or contradiction the
|
|
||||||
user needed to resolve was raised and answered upstream, and those answers are in the decision
|
|
||||||
history above. Treat the request as settled, ground your requirements in what you actually read,
|
|
||||||
and do not ask the user anything. Do not design or plan yet.
|
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ message; call `emit_artifact` and nothing else.
|
|||||||
|
|
||||||
## Inputs available to you
|
## Inputs available to you
|
||||||
|
|
||||||
- `analysis` artifact — structured findings from the analyst stage (goal, constraints,
|
- `dod` artifact — the fixed acceptance contract from the analyst. Every implementation and
|
||||||
risks, open questions).
|
review stage must consume it and must not widen it.
|
||||||
- Decision history — the session's decision journal, including any user steering received
|
- Decision history — the session's decision journal, including any user steering received
|
||||||
at approval gates. User steering takes priority over your own judgment; honour it
|
at approval gates. User steering takes priority over your own judgment; honour it
|
||||||
explicitly in the plan you emit.
|
explicitly in the plan you emit.
|
||||||
@@ -48,7 +48,7 @@ Emit a JSON object that validates against the `execution_plan` schema:
|
|||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
**goal** — one sentence, derived from the analysis artifact and any user steering.
|
**goal** — one sentence, derived from the DoD artifact and any user steering.
|
||||||
|
|
||||||
**stages** — ordered list; each stage must:
|
**stages** — ordered list; each stage must:
|
||||||
- Have a unique `id` in `snake_case`.
|
- Have a unique `id` in `snake_case`.
|
||||||
@@ -60,6 +60,8 @@ Emit a JSON object that validates against the `execution_plan` schema:
|
|||||||
llm-emitted kind.
|
llm-emitted kind.
|
||||||
- Declare `needs`: every upstream artifact id the stage's prompt references. Every id in
|
- Declare `needs`: every upstream artifact id the stage's prompt references. Every id in
|
||||||
`needs` must be `produces`d by a strictly earlier stage.
|
`needs` must be `produces`d by a strictly earlier stage.
|
||||||
|
- Every implementation and review stage must include the session-scoped `dod` artifact in
|
||||||
|
`needs`. The compiler also enforces this seam.
|
||||||
- Include `tools` per stage as it needs them, using only names from this set:
|
- Include `tools` per stage as it needs them, using only names from this set:
|
||||||
`file_read`, `file_write`, `file_edit`, `list_dir`, `shell`, `task_context`, `task_update`,
|
`file_read`, `file_write`, `file_edit`, `list_dir`, `shell`, `task_context`, `task_update`,
|
||||||
`task_search`. Stages that write or edit files take the file set
|
`task_search`. Stages that write or edit files take the file set
|
||||||
@@ -141,12 +143,15 @@ Emit a JSON object that validates against the `execution_plan` schema:
|
|||||||
`field`: `"verdict"`, `value`: `"approved"`, `operator`: `"eq"` → `to: "done"`
|
`field`: `"verdict"`, `value`: `"approved"`, `operator`: `"eq"` → `to: "done"`
|
||||||
- `"type": "artifact_field_equals"`, `artifact_id`: reviewer's produces id,
|
- `"type": "artifact_field_equals"`, `artifact_id`: reviewer's produces id,
|
||||||
`field`: `"verdict"`, `value`: `"approved"`, `operator`: `"neq"` → `to: "<implement_stage_id>"`
|
`field`: `"verdict"`, `value`: `"approved"`, `operator`: `"neq"` → `to: "<implement_stage_id>"`
|
||||||
|
- A reviewer prompt must use only DoD rows tagged `verified_by: reviewer`: approve iff every such
|
||||||
|
row is met and no `out_of_scope` item was introduced. It must cite failed criterion ids and may
|
||||||
|
not invent new requirements. Criteria tagged `gate` are already decided upstream.
|
||||||
- Every stage except the first must have an inbound edge from an earlier stage; every
|
- Every stage except the first must have an inbound edge from an earlier stage; every
|
||||||
stage must have an outbound edge. Unreachable stages fail to compile.
|
stage must have an outbound edge. Unreachable stages fail to compile.
|
||||||
|
|
||||||
## Constraints
|
## Constraints
|
||||||
|
|
||||||
- Do not add stages, roles, or tools not justified by the analysis artifact.
|
- Do not add stages, roles, or tools not justified by the DoD artifact.
|
||||||
- Do not reference artifact ids that no stage in this plan produces (except ids that
|
- Do not reference artifact ids that no stage in this plan produces (except ids that
|
||||||
pre-exist in the session, such as `analysis`).
|
pre-exist in the session, such as `analysis`).
|
||||||
- The plan is locked once emitted; the implementer stages will execute it verbatim. Be
|
- The plan is locked once emitted; the implementer stages will execute it verbatim. Be
|
||||||
|
|||||||
@@ -22,24 +22,47 @@ Two checks, both grounded in what you actually read:
|
|||||||
endpoint.
|
endpoint.
|
||||||
|
|
||||||
Emit the `discovery` artifact by calling **`emit_artifact`** with:
|
Emit the `discovery` artifact by calling **`emit_artifact`** with:
|
||||||
|
- `brief`: the complete comprehension brief. Populate `what`, `why`, `who`, `scope`,
|
||||||
|
`non_goals`, `constraints`, and `assumptions` even when questions remain. Use assumptions for
|
||||||
|
reasonable, visible defaults instead of parking on micro-decisions.
|
||||||
- `ready`: `true` when the request is clear and grounded enough to hand to the analyst; `false`
|
- `ready`: `true` when the request is clear and grounded enough to hand to the analyst; `false`
|
||||||
when you are raising questions.
|
when you are raising questions.
|
||||||
- `questions`: the open questions (empty when `ready` is true). Batch **all** of them into this
|
- `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:
|
one list — do not ask one at a time. Each entry is an object:
|
||||||
- `prompt` (required): the question, in full.
|
- `prompt` (required): the question, in full.
|
||||||
- `options` (optional): suggested answers as strings, whenever the answer is a choice among
|
- `options` (**required whenever the answer is a choice among known alternatives** — and it
|
||||||
known alternatives.
|
almost always is: stack, library, endpoint, layout, priority are all choices among things you
|
||||||
|
can name). Provide 2–4 concrete prefilled answers as strings. An open-ended question with no
|
||||||
|
`options` is only acceptable when no candidate set exists at all. Empty `options` on a
|
||||||
|
choice-question is a defect — enumerate the real candidates you found in the repo.
|
||||||
- `multiSelect` (optional, default false): true if more than one option may apply.
|
- `multiSelect` (optional, default false): true if more than one option may apply.
|
||||||
- `header` (optional): a 1–2 word label (e.g. "Scope", "Stack", "Endpoint").
|
- `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 —
|
**Default to proceeding.** First inspect enough of the repository to enumerate the whole question
|
||||||
emit `{"ready": true, "questions": []}` and let the analyst take over. Only ask when a genuine
|
surface, then ask every genuine operator-only question in one batch. For a clear, well-grounded
|
||||||
|
request, proceed with visible assumptions. Do not raise one question, re-enter, and discover
|
||||||
|
another question that the same initial inspection could have exposed.
|
||||||
|
|
||||||
|
**Converge once answered.** If the decision history above already contains the operator's answers
|
||||||
|
to your questions, you are done vetting — emit the brief with `ready: true` and empty `questions`.
|
||||||
|
Do NOT re-explore the repo hunting for new questions after the operator has answered; fold their
|
||||||
|
answers into the brief and hand off. You get **one** clarification round: ask everything up front,
|
||||||
|
then commit. Endless re-inspection is a failure, not diligence.
|
||||||
fork or contradiction blocks planning. Do not nag, and do not re-ask what the operator has
|
fork or contradiction blocks planning. Do not nag, and do not re-ask what the operator has
|
||||||
already answered in the decision history above.
|
already answered in the decision history above.
|
||||||
|
|
||||||
Example (needs input):
|
Example (needs input):
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
"brief": {
|
||||||
|
"what": "Build a browser client for the existing session API.",
|
||||||
|
"why": "Operators need a visual session surface.",
|
||||||
|
"who": ["operators"],
|
||||||
|
"scope": ["browser session client"],
|
||||||
|
"non_goals": ["server protocol redesign"],
|
||||||
|
"constraints": ["reuse the existing endpoint"],
|
||||||
|
"assumptions": []
|
||||||
|
},
|
||||||
"ready": false,
|
"ready": false,
|
||||||
"questions": [
|
"questions": [
|
||||||
{"prompt": "Which frontend stack should the UI target?",
|
{"prompt": "Which frontend stack should the UI target?",
|
||||||
@@ -52,5 +75,17 @@ Example (needs input):
|
|||||||
|
|
||||||
Example (clear — the common case):
|
Example (clear — the common case):
|
||||||
```json
|
```json
|
||||||
{ "ready": true, "questions": [] }
|
{
|
||||||
|
"brief": {
|
||||||
|
"what": "Add the requested deterministic validation gate.",
|
||||||
|
"why": "Prevent invalid output from reaching review.",
|
||||||
|
"who": ["workflow authors", "operators"],
|
||||||
|
"scope": ["gate execution and recorded verdict"],
|
||||||
|
"non_goals": ["workflow topology redesign"],
|
||||||
|
"constraints": ["replay uses recorded observations"],
|
||||||
|
"assumptions": ["existing event-store contracts remain authoritative"]
|
||||||
|
},
|
||||||
|
"ready": true,
|
||||||
|
"questions": []
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
+31
-8
@@ -154,6 +154,12 @@ class FileEditTool(
|
|||||||
) ->
|
) ->
|
||||||
ValidationResult.Invalid("Missing 'target' or 'replacement' parameter for 'replace' operation.")
|
ValidationResult.Invalid("Missing 'target' or 'replacement' parameter for 'replace' operation.")
|
||||||
|
|
||||||
|
// Anchor validity is checkable now, so reject a bad target BEFORE the approval gate
|
||||||
|
// fires — mirroring read/write's file-exists / read-before-write pre-checks. Without
|
||||||
|
// this the operator is prompted to approve an edit that then dies "Target not found"
|
||||||
|
// at execute time. Recoverable: the model corrects the anchor and retries.
|
||||||
|
operation == "replace" -> validateReplaceAnchor(path, pathString, request)
|
||||||
|
|
||||||
operation == "patch" && !request.parameters.containsKey("patch") ->
|
operation == "patch" && !request.parameters.containsKey("patch") ->
|
||||||
ValidationResult.Invalid("Missing 'patch' parameter for 'patch' operation.")
|
ValidationResult.Invalid("Missing 'patch' parameter for 'patch' operation.")
|
||||||
|
|
||||||
@@ -177,6 +183,29 @@ class FileEditTool(
|
|||||||
private fun isPathAllowed(path: Path): Boolean =
|
private fun isPathAllowed(path: Path): Boolean =
|
||||||
PathJail.isContained(path, normalizedAllowedPaths)
|
PathJail.isContained(path, normalizedAllowedPaths)
|
||||||
|
|
||||||
|
/** Valid iff the replace target occurs exactly once. Same 0/>1 messaging as [replace] at execute. */
|
||||||
|
private fun validateReplaceAnchor(path: Path, pathString: String, request: ToolRequest): ValidationResult {
|
||||||
|
val target = request.parameters["target"] as? String ?: return ValidationResult.Valid
|
||||||
|
val content = Files.readString(path)
|
||||||
|
return when (val occurrences = content.split(target).size - 1) {
|
||||||
|
1 -> ValidationResult.Valid
|
||||||
|
0 -> ValidationResult.Invalid(targetNotFoundMessage(pathString, content, target))
|
||||||
|
else -> ValidationResult.Invalid(targetAmbiguousMessage(pathString, occurrences))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun targetNotFoundMessage(pathString: String, content: String, target: String): String =
|
||||||
|
buildString {
|
||||||
|
append("Target not found in $pathString")
|
||||||
|
nearestLine(content, target)?.let {
|
||||||
|
append(". Did you mean (whitespace/content may differ): $it ?")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun targetAmbiguousMessage(pathString: String, occurrences: Int): String =
|
||||||
|
"Target found $occurrences times in $pathString, expected exactly once — " +
|
||||||
|
"extend it with surrounding lines to make it unique."
|
||||||
|
|
||||||
private fun mapExceptionToValidationResult(e: Throwable): ValidationResult =
|
private fun mapExceptionToValidationResult(e: Throwable): ValidationResult =
|
||||||
when (e) {
|
when (e) {
|
||||||
is InvalidPathException -> ValidationResult.Invalid("Invalid path format: ${e.message}")
|
is InvalidPathException -> ValidationResult.Invalid("Invalid path format: ${e.message}")
|
||||||
@@ -270,18 +299,12 @@ class FileEditTool(
|
|||||||
// always whitespace/content drift the model can correct from a nudge.
|
// always whitespace/content drift the model can correct from a nudge.
|
||||||
0 -> ToolResult.Failure(
|
0 -> ToolResult.Failure(
|
||||||
invocationId = request.invocationId,
|
invocationId = request.invocationId,
|
||||||
reason = buildString {
|
reason = targetNotFoundMessage(pathString, currentContent, target),
|
||||||
append("Target not found in $pathString")
|
|
||||||
nearestLine(currentContent, target)?.let {
|
|
||||||
append(". Did you mean (whitespace/content may differ): $it ?")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
recoverable = true,
|
recoverable = true,
|
||||||
)
|
)
|
||||||
else -> ToolResult.Failure(
|
else -> ToolResult.Failure(
|
||||||
invocationId = request.invocationId,
|
invocationId = request.invocationId,
|
||||||
reason = "Target found $occurrences times in $pathString, expected exactly once — " +
|
reason = targetAmbiguousMessage(pathString, occurrences),
|
||||||
"extend it with surrounding lines to make it unique.",
|
|
||||||
recoverable = true,
|
recoverable = true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+28
@@ -95,6 +95,34 @@ class FileEditToolTest {
|
|||||||
assertTrue((result as ValidationResult.Invalid).reason.contains("File not found"))
|
assertTrue((result as ValidationResult.Invalid).reason.contains("File not found"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `validateRequest rejects a replace whose target is absent or ambiguous, before execute`(): Unit = runBlocking {
|
||||||
|
val tempDir = Files.createTempDirectory("file_edit_anchor")
|
||||||
|
val filePath = tempDir.resolve("test.txt")
|
||||||
|
Files.writeString(filePath, "alpha\nbeta\nbeta\n")
|
||||||
|
val tool = FileEditTool(allowedPaths = setOf(tempDir))
|
||||||
|
|
||||||
|
val absent = tool.validateRequest(
|
||||||
|
createRequest(mapOf("operation" to "replace", "path" to filePath.toString(),
|
||||||
|
"target" to "gamma", "replacement" to "x")),
|
||||||
|
)
|
||||||
|
assertTrue(absent is ValidationResult.Invalid)
|
||||||
|
assertTrue((absent as ValidationResult.Invalid).reason.contains("Target not found"))
|
||||||
|
|
||||||
|
val ambiguous = tool.validateRequest(
|
||||||
|
createRequest(mapOf("operation" to "replace", "path" to filePath.toString(),
|
||||||
|
"target" to "beta", "replacement" to "x")),
|
||||||
|
)
|
||||||
|
assertTrue(ambiguous is ValidationResult.Invalid)
|
||||||
|
assertTrue((ambiguous as ValidationResult.Invalid).reason.contains("expected exactly once"))
|
||||||
|
|
||||||
|
val unique = tool.validateRequest(
|
||||||
|
createRequest(mapOf("operation" to "replace", "path" to filePath.toString(),
|
||||||
|
"target" to "alpha", "replacement" to "x")),
|
||||||
|
)
|
||||||
|
assertEquals(ValidationResult.Valid, unique)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `validateRequest returns Invalid for missing parameters`(): Unit = runBlocking {
|
fun `validateRequest returns Invalid for missing parameters`(): Unit = runBlocking {
|
||||||
val tempDir = Files.createTempDirectory("file_edit_test")
|
val tempDir = Files.createTempDirectory("file_edit_test")
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ Adapter for `core:transitions` and `core:inference` workflow interfaces. Depends
|
|||||||
- `ExecutionPlanCompiler` converts the parsed `ExecutionPlanModel` into a runnable plan for `core:transitions`; compilation is deterministic given the same input model.
|
- `ExecutionPlanCompiler` converts the parsed `ExecutionPlanModel` into a runnable plan for `core:transitions`; compilation is deterministic given the same input model.
|
||||||
- `PlanLinter` enforces structural rules (e.g. unreachable stages are rejected); linting failures throw `WorkflowValidationException`.
|
- `PlanLinter` enforces structural rules (e.g. unreachable stages are rejected); linting failures throw `WorkflowValidationException`.
|
||||||
- `PlanDerivedManifest` exposes the write manifest derived from a plan.
|
- `PlanDerivedManifest` exposes the write manifest derived from a plan.
|
||||||
- For a plan with no explicit build expectation, `ExecutionPlanCompiler` marks every write-declaring stage for the runtime auto build gate; plans with no declared writes are not represented as compiler-verified.
|
- For a plan with no explicit build expectation, `ExecutionPlanCompiler` marks every write-declaring stage and the graph-terminal stage for the runtime auto build gate; plans with no declared writes are not represented as compiler-verified.
|
||||||
|
- When a plan declares a `dod` producer, freestyle implementation/review stages consume that session-scoped artifact; reviewer prompts are compiler-bound to its reviewer-owned criteria.
|
||||||
|
- `Lsp4jDiagnosticsRunner` uses LSP 3.17 pull diagnostics for registered languages and degrades to the static one-shot floor when a server is unavailable.
|
||||||
|
|
||||||
## Work Guidance
|
## Work Guidance
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ dependencies {
|
|||||||
implementation(project(":core:events"))
|
implementation(project(":core:events"))
|
||||||
implementation(project(":core:artifacts"))
|
implementation(project(":core:artifacts"))
|
||||||
implementation(project(":core:tools"))
|
implementation(project(":core:tools"))
|
||||||
|
implementation(project(":core:kernel"))
|
||||||
|
implementation("org.eclipse.lsp4j:org.eclipse.lsp4j:0.23.1")
|
||||||
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"
|
||||||
|
|||||||
+44
-4
@@ -19,6 +19,8 @@ import java.nio.file.Path
|
|||||||
|
|
||||||
private const val TERMINAL = "done"
|
private const val TERMINAL = "done"
|
||||||
private const val RECOVERY_STAGE = "recovery"
|
private const val RECOVERY_STAGE = "recovery"
|
||||||
|
private const val DOD_ARTIFACT = "dod"
|
||||||
|
private const val STATIC_FLOOR_COMMAND = "correx-static-floor"
|
||||||
|
|
||||||
// Synthesized recovery stage prompt. Freestyle plans are LLM-emitted and never declare a recovery
|
// Synthesized recovery stage prompt. Freestyle plans are LLM-emitted and never declare a recovery
|
||||||
// stage, but the kernel's retry-agency guard routes a write-less stage's unfixable gate failure to
|
// stage, but the kernel's retry-agency guard routes a write-less stage's unfixable gate failure to
|
||||||
@@ -85,7 +87,16 @@ class ExecutionPlanCompiler(
|
|||||||
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
|
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
fun compile(planJson: String, workflowId: String): WorkflowGraph {
|
/**
|
||||||
|
* @param sessionArtifactIds artifact ids already produced earlier in the same freestyle session
|
||||||
|
* (e.g. the planning-phase `dod`). The execution plan never re-declares these, so they must be
|
||||||
|
* passed in for the cross-workflow `needs` seam (#264) to thread the DoD into impl/review stages.
|
||||||
|
*/
|
||||||
|
fun compile(
|
||||||
|
planJson: String,
|
||||||
|
workflowId: String,
|
||||||
|
sessionArtifactIds: Set<String> = emptySet(),
|
||||||
|
): WorkflowGraph {
|
||||||
val plan = runCatching { mapper.readValue<ExecutionPlanModel>(planJson) }
|
val plan = runCatching { mapper.readValue<ExecutionPlanModel>(planJson) }
|
||||||
.getOrElse { throw WorkflowValidationException("Failed to parse execution_plan JSON: ${it.message}") }
|
.getOrElse { throw WorkflowValidationException("Failed to parse execution_plan JSON: ${it.message}") }
|
||||||
if (plan.stages.isEmpty()) throw WorkflowValidationException("execution_plan has no stages")
|
if (plan.stages.isEmpty()) throw WorkflowValidationException("execution_plan has no stages")
|
||||||
@@ -115,10 +126,13 @@ class ExecutionPlanCompiler(
|
|||||||
// FileWritten manifest — the plan's declared kinds don't reveal code-ness, but the written
|
// FileWritten manifest — the plan's declared kinds don't reveal code-ness, but the written
|
||||||
// paths do — so a docs-only plan is left alone and a code plan is always gated.
|
// paths do — so a docs-only plan is left alone and a code plan is always gated.
|
||||||
val autoGateStages: Set<String> = if (declaredExpectations.values.all { it == BuildExpectation.NONE }) {
|
val autoGateStages: Set<String> = if (declaredExpectations.values.all { it == BuildExpectation.NONE }) {
|
||||||
plan.stages.filter { it.writes.any { path -> path.isNotBlank() } }.map { it.id }.toSet()
|
val writing = plan.stages.filter { it.writes.any { path -> path.isNotBlank() } }.map { it.id }.toSet()
|
||||||
|
if (writing.isEmpty()) emptySet() else writing + terminalStageId(plan)
|
||||||
} else {
|
} else {
|
||||||
emptySet()
|
emptySet()
|
||||||
}
|
}
|
||||||
|
val sessionArtifacts =
|
||||||
|
plan.stages.mapNotNull { it.produces.takeIf(String::isNotBlank) }.toSet() + sessionArtifactIds
|
||||||
|
|
||||||
val stageMap = plan.stages.associate { s ->
|
val stageMap = plan.stages.associate { s ->
|
||||||
// `produces` is the unique slot name referenced by needs/edges; `kind` selects the
|
// `produces` is the unique slot name referenced by needs/edges; `kind` selects the
|
||||||
@@ -137,7 +151,7 @@ class ExecutionPlanCompiler(
|
|||||||
).sorted()
|
).sorted()
|
||||||
StageId(s.id) to StageConfig(
|
StageId(s.id) to StageConfig(
|
||||||
produces = listOf(TypedArtifactSlot(name = ArtifactId(s.produces), kind = kind)),
|
produces = listOf(TypedArtifactSlot(name = ArtifactId(s.produces), kind = kind)),
|
||||||
needs = s.needs.map { ArtifactId(it) }.toSet(),
|
needs = (s.needs + requiredSessionArtifacts(s, sessionArtifacts)).map { ArtifactId(it) }.toSet(),
|
||||||
// Every stage gets the full registered tool universe. The architect's per-stage
|
// Every stage gets the full registered tool universe. The architect's per-stage
|
||||||
// `tools` pick was advisory and mis-scoped stages (e.g. an edit-shaped stage granted
|
// `tools` pick was advisory and mis-scoped stages (e.g. an edit-shaped stage granted
|
||||||
// file_write but not file_edit → forced full-file rewrites; a repair stage unable to
|
// file_write but not file_edit → forced full-file rewrites; a repair stage unable to
|
||||||
@@ -146,6 +160,7 @@ class ExecutionPlanCompiler(
|
|||||||
// architect's pick only when no universe was supplied (bare-constructor unit tests).
|
// architect's pick only when no universe was supplied (bare-constructor unit tests).
|
||||||
allowedTools = knownTools.ifEmpty { s.tools.toSet() },
|
allowedTools = knownTools.ifEmpty { s.tools.toSet() },
|
||||||
writeManifest = writeManifest,
|
writeManifest = writeManifest,
|
||||||
|
staticAnalysis = staticFloorCommands(writeManifest),
|
||||||
// Option A: the concrete (non-glob) subset of the declared writes are expected files
|
// Option A: the concrete (non-glob) subset of the declared writes are expected files
|
||||||
// the contract gate will require to exist. Globs (src/**) are write-permission scopes,
|
// the contract gate will require to exist. Globs (src/**) are write-permission scopes,
|
||||||
// not existence guarantees, so they are excluded here.
|
// not existence guarantees, so they are excluded here.
|
||||||
@@ -156,7 +171,7 @@ class ExecutionPlanCompiler(
|
|||||||
semanticReview = s.semanticReview,
|
semanticReview = s.semanticReview,
|
||||||
tokenBudget = DEFAULT_STAGE_TOKEN_BUDGET,
|
tokenBudget = DEFAULT_STAGE_TOKEN_BUDGET,
|
||||||
generationConfig = defaultStageGeneration,
|
generationConfig = defaultStageGeneration,
|
||||||
metadata = mapOf("promptInline" to s.prompt),
|
metadata = mapOf("role" to s.role, "promptInline" to reviewerBoundPrompt(s, DOD_ARTIFACT in sessionArtifacts)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Inject a recovery stage unless the plan already declares one. Reached only by the kernel's
|
// Inject a recovery stage unless the plan already declares one. Reached only by the kernel's
|
||||||
@@ -291,6 +306,31 @@ class ExecutionPlanCompiler(
|
|||||||
private fun terminalStageId(plan: ExecutionPlanModel): String =
|
private fun terminalStageId(plan: ExecutionPlanModel): String =
|
||||||
plan.edges.firstOrNull { it.to == TERMINAL }?.from ?: plan.stages.last().id
|
plan.edges.firstOrNull { it.to == TERMINAL }?.from ?: plan.stages.last().id
|
||||||
|
|
||||||
|
private fun requiredSessionArtifacts(stage: PlanStage, sessionArtifacts: Set<String>): List<String> =
|
||||||
|
if (
|
||||||
|
DOD_ARTIFACT in sessionArtifacts &&
|
||||||
|
stage.role.lowercase() in setOf("impl", "implementer", "review", "reviewer")
|
||||||
|
) {
|
||||||
|
listOf(DOD_ARTIFACT)
|
||||||
|
} else {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun reviewerBoundPrompt(stage: PlanStage, dodPresent: Boolean): String {
|
||||||
|
if (!dodPresent || stage.role.lowercase() !in setOf("review", "reviewer")) return stage.prompt
|
||||||
|
return stage.prompt + "\n\n" +
|
||||||
|
"The `dod` input artifact is your sole acceptance rubric. Judge only criteria tagged " +
|
||||||
|
"`verified_by: reviewer`; deterministic gates own `verified_by: gate`. Approve if and only if " +
|
||||||
|
"every reviewer criterion is met and no `out_of_scope` item was introduced. On rejection, " +
|
||||||
|
"cite the unmet criterion ids. Never add or imply criteria outside the DoD."
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun staticFloorCommands(writeManifest: List<String>): List<String> {
|
||||||
|
return writeManifest.takeIf { it.isNotEmpty() }
|
||||||
|
?.let { listOf("$STATIC_FLOOR_COMMAND -- ${it.joinToString(" ")}") }
|
||||||
|
?: emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
private fun isGlob(path: String): Boolean = path.any { it == '*' || it == '?' || it == '[' }
|
private fun isGlob(path: String): Boolean = path.any { it == '*' || it == '?' || it == '[' }
|
||||||
|
|
||||||
private fun validateReachability(
|
private fun validateReachability(
|
||||||
|
|||||||
+1
@@ -10,6 +10,7 @@ data class ExecutionPlanModel(
|
|||||||
|
|
||||||
data class PlanStage(
|
data class PlanStage(
|
||||||
val id: String = "",
|
val id: String = "",
|
||||||
|
val role: String = "",
|
||||||
val prompt: String = "",
|
val prompt: String = "",
|
||||||
val produces: String = "",
|
val produces: String = "",
|
||||||
val kind: String? = null,
|
val kind: String? = null,
|
||||||
|
|||||||
+142
@@ -0,0 +1,142 @@
|
|||||||
|
package com.correx.infrastructure.workflow
|
||||||
|
|
||||||
|
import com.correx.core.events.events.LspDiagnostic
|
||||||
|
import com.correx.core.kernel.orchestration.LspDiagnosticsRequest
|
||||||
|
import com.correx.core.kernel.orchestration.LspDiagnosticsResult
|
||||||
|
import com.correx.core.kernel.orchestration.LspDiagnosticsRunner
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.eclipse.lsp4j.ClientCapabilities
|
||||||
|
import org.eclipse.lsp4j.DidOpenTextDocumentParams
|
||||||
|
import org.eclipse.lsp4j.DocumentDiagnosticParams
|
||||||
|
import org.eclipse.lsp4j.InitializeParams
|
||||||
|
import org.eclipse.lsp4j.InitializedParams
|
||||||
|
import org.eclipse.lsp4j.MessageActionItem
|
||||||
|
import org.eclipse.lsp4j.MessageParams
|
||||||
|
import org.eclipse.lsp4j.PublishDiagnosticsParams
|
||||||
|
import org.eclipse.lsp4j.ShowMessageRequestParams
|
||||||
|
import org.eclipse.lsp4j.TextDocumentIdentifier
|
||||||
|
import org.eclipse.lsp4j.TextDocumentItem
|
||||||
|
import org.eclipse.lsp4j.launch.LSPLauncher
|
||||||
|
import org.eclipse.lsp4j.services.LanguageClient
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.util.concurrent.CompletableFuture
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
|
|
||||||
|
data class LspServerSpec(val id: String, val command: List<String>, val languageId: String)
|
||||||
|
|
||||||
|
class LspServerRegistry(
|
||||||
|
private val byExtension: Map<String, LspServerSpec> = defaults,
|
||||||
|
) {
|
||||||
|
fun serverFor(path: String): LspServerSpec? = byExtension[path.substringAfterLast('.', "").lowercase()]
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val typescript = LspServerSpec(
|
||||||
|
"tsserver",
|
||||||
|
listOf("typescript-language-server", "--stdio"),
|
||||||
|
"typescriptreact",
|
||||||
|
)
|
||||||
|
private val rust = LspServerSpec("rust-analyzer", listOf("rust-analyzer"), "rust")
|
||||||
|
private val go = LspServerSpec("gopls", listOf("gopls"), "go")
|
||||||
|
private val clangd = LspServerSpec("clangd", listOf("clangd"), "cpp")
|
||||||
|
val defaults = mapOf(
|
||||||
|
"ts" to typescript.copy(languageId = "typescript"),
|
||||||
|
"tsx" to typescript,
|
||||||
|
"js" to typescript.copy(languageId = "javascript"),
|
||||||
|
"jsx" to typescript.copy(languageId = "javascriptreact"),
|
||||||
|
"rs" to rust,
|
||||||
|
"go" to go,
|
||||||
|
"c" to clangd.copy(languageId = "c"),
|
||||||
|
"cc" to clangd,
|
||||||
|
"cpp" to clangd,
|
||||||
|
"h" to clangd.copy(languageId = "c"),
|
||||||
|
"hpp" to clangd,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** LSP 3.17 pull-diagnostics adapter. Missing servers degrade to the static one-shot floor. */
|
||||||
|
class Lsp4jDiagnosticsRunner(
|
||||||
|
private val registry: LspServerRegistry = LspServerRegistry(),
|
||||||
|
private val timeoutSeconds: Long = 30,
|
||||||
|
) : LspDiagnosticsRunner {
|
||||||
|
override suspend fun pull(request: LspDiagnosticsRequest): LspDiagnosticsResult = withContext(Dispatchers.IO) {
|
||||||
|
val groups = request.paths
|
||||||
|
.mapNotNull { path -> registry.serverFor(path)?.let { it to path } }
|
||||||
|
.groupBy({ it.first }, { it.second })
|
||||||
|
if (groups.isEmpty()) {
|
||||||
|
return@withContext LspDiagnosticsResult(skippedReason = "no language server registered for touched files")
|
||||||
|
}
|
||||||
|
val diagnostics = mutableListOf<LspDiagnostic>()
|
||||||
|
val skipped = mutableListOf<String>()
|
||||||
|
groups.forEach { (server, paths) ->
|
||||||
|
runCatching { diagnostics += pullFromServer(request, server, paths) }
|
||||||
|
.onFailure {
|
||||||
|
if (it is CancellationException) throw it
|
||||||
|
skipped += "${server.id}: ${it.message ?: it::class.simpleName}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LspDiagnosticsResult(
|
||||||
|
server = groups.keys.joinToString(",") { it.id },
|
||||||
|
diagnostics = diagnostics,
|
||||||
|
skippedReason = skipped.takeIf { it.isNotEmpty() }?.joinToString("; "),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pullFromServer(
|
||||||
|
request: LspDiagnosticsRequest,
|
||||||
|
spec: LspServerSpec,
|
||||||
|
paths: List<String>,
|
||||||
|
): List<LspDiagnostic> {
|
||||||
|
val process = ProcessBuilder(spec.command).directory(request.workspaceRoot.toFile()).start()
|
||||||
|
val stderrDrain = Thread.ofVirtual().start { process.errorStream.bufferedReader().use { it.readText() } }
|
||||||
|
val launcher = LSPLauncher.createClientLauncher(QuietLanguageClient, process.inputStream, process.outputStream)
|
||||||
|
val listening = launcher.startListening()
|
||||||
|
val server = launcher.remoteProxy
|
||||||
|
return try {
|
||||||
|
server.initialize(
|
||||||
|
InitializeParams().apply {
|
||||||
|
rootUri = request.workspaceRoot.toUri().toString()
|
||||||
|
capabilities = ClientCapabilities()
|
||||||
|
},
|
||||||
|
).get(timeoutSeconds, TimeUnit.SECONDS)
|
||||||
|
server.initialized(InitializedParams())
|
||||||
|
paths.flatMap { path ->
|
||||||
|
val file = request.workspaceRoot.resolve(path).normalize()
|
||||||
|
val uri = file.toUri().toString()
|
||||||
|
server.textDocumentService.didOpen(
|
||||||
|
DidOpenTextDocumentParams(TextDocumentItem(uri, spec.languageId, 1, Files.readString(file))),
|
||||||
|
)
|
||||||
|
val report = server.textDocumentService.diagnostic(
|
||||||
|
DocumentDiagnosticParams(TextDocumentIdentifier(uri)),
|
||||||
|
).get(timeoutSeconds, TimeUnit.SECONDS)
|
||||||
|
report.relatedFullDocumentDiagnosticReport.items.orEmpty().map { diagnostic ->
|
||||||
|
LspDiagnostic(
|
||||||
|
path = path,
|
||||||
|
line = diagnostic.range.start.line,
|
||||||
|
character = diagnostic.range.start.character,
|
||||||
|
severity = diagnostic.severity?.name?.lowercase() ?: "error",
|
||||||
|
code = diagnostic.code?.let { if (it.isLeft) it.left else it.right.toString() },
|
||||||
|
message = diagnostic.message,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
runCatching { server.shutdown().get(timeoutSeconds, TimeUnit.SECONDS) }
|
||||||
|
server.exit()
|
||||||
|
listening.cancel(true)
|
||||||
|
process.destroy()
|
||||||
|
stderrDrain.join(TimeUnit.SECONDS.toMillis(1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private object QuietLanguageClient : LanguageClient {
|
||||||
|
override fun telemetryEvent(`object`: Any?) = Unit
|
||||||
|
override fun publishDiagnostics(diagnostics: PublishDiagnosticsParams) = Unit
|
||||||
|
override fun showMessage(messageParams: MessageParams) = Unit
|
||||||
|
override fun showMessageRequest(requestParams: ShowMessageRequestParams): CompletableFuture<MessageActionItem> =
|
||||||
|
CompletableFuture.completedFuture(null)
|
||||||
|
override fun logMessage(message: MessageParams) = Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
-8
@@ -35,6 +35,13 @@ object PlanGrounder {
|
|||||||
graph: WorkflowGraph,
|
graph: WorkflowGraph,
|
||||||
repoMapPaths: Set<String>,
|
repoMapPaths: Set<String>,
|
||||||
profileCommands: Map<String, String>,
|
profileCommands: Map<String, String>,
|
||||||
|
// False = no repo scan was recorded for this session, so [repoMapPaths] is "unknown", not
|
||||||
|
// "empty workspace". Scope grounding proves a path ABSENT, which an unknown set can't do —
|
||||||
|
// running it against a missing scan flags every real path as nonexistent (the apps/server
|
||||||
|
// "doesn't exist?!" false reject). Skip scope grounding then; the build-manifest check still
|
||||||
|
// runs (it degrades safely via manifestProducedByPlan). A recorded scan with 0 entries is a
|
||||||
|
// genuine greenfield workspace — scanned=true, so scope grounding still catches a missing scaffold.
|
||||||
|
scanned: Boolean = true,
|
||||||
): PlanGroundingResult {
|
): PlanGroundingResult {
|
||||||
val manifestInWorkspace = repoMapPaths.any(::isBuildManifest)
|
val manifestInWorkspace = repoMapPaths.any(::isBuildManifest)
|
||||||
val manifestProducedByPlan = graph.stages.values
|
val manifestProducedByPlan = graph.stages.values
|
||||||
@@ -60,14 +67,16 @@ object PlanGrounder {
|
|||||||
// scope that matches NOTHING in the recorded repo map AND that no plan stage creates a file
|
// scope that matches NOTHING in the recorded repo map AND that no plan stage creates a file
|
||||||
// under is grounded on a directory that will never exist — the exact session-a35cf1e3
|
// under is grounded on a directory that will never exist — the exact session-a35cf1e3
|
||||||
// "scoped to EXISTING frontend/ that isn't there" failure. Caught at stage 0, not stage 11.
|
// "scoped to EXISTING frontend/ that isn't there" failure. Caught at stage 0, not stage 11.
|
||||||
cfg.touches
|
if (scanned) {
|
||||||
.filter { glob -> !scopeSatisfied(glob, repoMapPaths, createdPaths) }
|
cfg.touches
|
||||||
.forEach { glob ->
|
.filter { glob -> !scopeSatisfied(glob, repoMapPaths, createdPaths) }
|
||||||
add(
|
.forEach { glob ->
|
||||||
"stage ${id.value}: scoped to '$glob' but nothing under it exists in the repo map " +
|
add(
|
||||||
"and no plan stage creates a file there — scaffold that path first or fix the scope.",
|
"stage ${id.value}: scoped to '$glob' but nothing under it exists in the repo map " +
|
||||||
)
|
"and no plan stage creates a file there — scaffold that path first or fix the scope.",
|
||||||
}
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -37,7 +37,7 @@ object PlanLinter {
|
|||||||
* planning phase, not by any plan stage. The architect prompt explicitly allows `needs` to
|
* planning phase, not by any plan stage. The architect prompt explicitly allows `needs` to
|
||||||
* reference these (notably `analysis`), so the linter must treat them as available producers.
|
* reference these (notably `analysis`), so the linter must treat them as available producers.
|
||||||
*/
|
*/
|
||||||
private val seedArtifacts = setOf("analysis")
|
private val seedArtifacts = setOf("analysis", "dod")
|
||||||
|
|
||||||
fun lint(graph: WorkflowGraph, seeds: Set<String> = seedArtifacts): PlanLintResult =
|
fun lint(graph: WorkflowGraph, seeds: Set<String> = seedArtifacts): PlanLintResult =
|
||||||
PlanLintResult(
|
PlanLintResult(
|
||||||
|
|||||||
+57
@@ -499,6 +499,63 @@ class ExecutionPlanCompilerTest {
|
|||||||
graph.stages[StageId("entry")]!!.autoBuildGate,
|
graph.stages[StageId("entry")]!!.autoBuildGate,
|
||||||
"implementation stages must not defer compiler coverage to a terminal verifier",
|
"implementation stages must not defer compiler coverage to a terminal verifier",
|
||||||
)
|
)
|
||||||
|
assertTrue(graph.stages[StageId("entry")]!!.staticAnalysis.single().contains("src/main.tsx"))
|
||||||
|
assertTrue(graph.stages[StageId("views")]!!.staticAnalysis.single().contains("src/App.tsx"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `non-writing review terminal receives the auto build gate`() {
|
||||||
|
val planned = """
|
||||||
|
{
|
||||||
|
"goal": "implement then review",
|
||||||
|
"stages": [
|
||||||
|
{ "id": "impl", "role": "implementer", "prompt": "write code", "produces": "code",
|
||||||
|
"kind": "react_entry", "writes": ["src/main.tsx"] },
|
||||||
|
{ "id": "review", "role": "reviewer", "prompt": "review against dod", "produces": "verdict",
|
||||||
|
"kind": "react_component", "needs": ["code"] }
|
||||||
|
],
|
||||||
|
"edges": [
|
||||||
|
{ "from": "impl", "to": "review", "condition": { "type": "always_true" } },
|
||||||
|
{ "from": "review", "to": "done", "condition": { "type": "always_true" } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
val graph = codeCompiler.compile(planned, "wf")
|
||||||
|
assertTrue(graph.stages.getValue(StageId("review")).autoBuildGate)
|
||||||
|
// No dod in session → nothing to thread (execution plan never re-produces it).
|
||||||
|
assertTrue(graph.stages.getValue(StageId("review")).needs.none { it.value == "dod" })
|
||||||
|
|
||||||
|
// Planning-phase dod present in the session → threaded into impl + review needs (#264 seam).
|
||||||
|
val withDod = codeCompiler.compile(planned, "wf", sessionArtifactIds = setOf("dod"))
|
||||||
|
assertTrue(withDod.stages.getValue(StageId("review")).needs.any { it.value == "dod" })
|
||||||
|
assertTrue(withDod.stages.getValue(StageId("impl")).needs.any { it.value == "dod" })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `implementation and review consume dod only when the plan produces it`() {
|
||||||
|
val planned = """
|
||||||
|
{
|
||||||
|
"goal": "define, implement, and review",
|
||||||
|
"stages": [
|
||||||
|
{ "id": "analyst", "role": "analyst", "prompt": "define dod", "produces": "dod",
|
||||||
|
"kind": "react_component" },
|
||||||
|
{ "id": "impl", "role": "implementer", "prompt": "write code", "produces": "code",
|
||||||
|
"kind": "react_entry", "writes": ["src/main.tsx"] },
|
||||||
|
{ "id": "review", "role": "reviewer", "prompt": "review", "produces": "verdict",
|
||||||
|
"kind": "react_component", "needs": ["code"] }
|
||||||
|
],
|
||||||
|
"edges": [
|
||||||
|
{ "from": "analyst", "to": "impl", "condition": { "type": "always_true" } },
|
||||||
|
{ "from": "impl", "to": "review", "condition": { "type": "always_true" } },
|
||||||
|
{ "from": "review", "to": "done", "condition": { "type": "always_true" } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val graph = codeCompiler.compile(planned, "wf-with-dod")
|
||||||
|
|
||||||
|
assertTrue(graph.stages.getValue(StageId("impl")).needs.any { it.value == "dod" })
|
||||||
|
assertTrue(graph.stages.getValue(StageId("review")).needs.any { it.value == "dod" })
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
+2
-1
@@ -28,6 +28,7 @@ class FreestylePlanningWorkflowTest {
|
|||||||
private val registry = DefaultArtifactKindRegistry().apply {
|
private val registry = DefaultArtifactKindRegistry().apply {
|
||||||
register(llmKind("discovery"))
|
register(llmKind("discovery"))
|
||||||
register(llmKind("analysis"))
|
register(llmKind("analysis"))
|
||||||
|
register(llmKind("dod"))
|
||||||
register(llmKind("execution_plan"))
|
register(llmKind("execution_plan"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,7 +65,7 @@ class FreestylePlanningWorkflowTest {
|
|||||||
"architect must produce execution_plan",
|
"architect must produce execution_plan",
|
||||||
)
|
)
|
||||||
|
|
||||||
assertTrue(architect.needs.map { it.value }.contains("analysis"), "architect needs analysis")
|
assertTrue(architect.needs.map { it.value }.contains("dod"), "architect needs the fixed DoD")
|
||||||
|
|
||||||
val analystToArchitect = graph.transitions.first { it.from == StageId("analyst") }
|
val analystToArchitect = graph.transitions.first { it.from == StageId("analyst") }
|
||||||
assertEquals(StageId("architect"), analystToArchitect.to)
|
assertEquals(StageId("architect"), analystToArchitect.to)
|
||||||
|
|||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
package com.correx.infrastructure.workflow
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
|
||||||
|
class LspServerRegistryTest {
|
||||||
|
private val registry = LspServerRegistry()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `registry maps language extensions to servers`() {
|
||||||
|
assertEquals("tsserver", registry.serverFor("src/App.tsx")?.id)
|
||||||
|
assertEquals("rust-analyzer", registry.serverFor("src/main.rs")?.id)
|
||||||
|
assertEquals("gopls", registry.serverFor("cmd/main.go")?.id)
|
||||||
|
assertEquals("clangd", registry.serverFor("src/main.cpp")?.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unknown extension falls through`() {
|
||||||
|
assertNull(registry.serverFor("README.md"))
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
@@ -65,6 +65,13 @@ class PlanGrounderTest {
|
|||||||
assertTrue(r.findings.single().contains("frontend/**"))
|
assertTrue(r.findings.single().contains("frontend/**"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `scanned=false skips scope grounding — a missing scan can't prove a path absent`() {
|
||||||
|
val g = graph("impl" to StageConfig(touches = listOf("frontend/**", "apps/server/**")))
|
||||||
|
val r = PlanGrounder.ground(g, repoMapPaths = emptySet(), profileCommands = cmds, scanned = false)
|
||||||
|
assertEquals(PlanGroundingVerdict.PASS, r.verdict)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `a scope satisfied by an existing repo path grounds`() {
|
fun `a scope satisfied by an existing repo path grounds`() {
|
||||||
val g = graph("impl" to StageConfig(touches = listOf("frontend/**")))
|
val g = graph("impl" to StageConfig(touches = listOf("frontend/**")))
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
End-to-end integration tests that wire multiple core modules together without live LLM or network. Covers: `SessionOrchestrator` full flows, tool-call gate, freestyle approval gate, clarification loop, steering preemption, graph reroute, artifact injection, workspace-scoped tool registry, validation pipeline integration, critique calibration wiring, session rehydration, repo-map reuse, and subagent runner seam.
|
End-to-end integration tests that wire multiple core modules together without live LLM or network. Covers: `SessionOrchestrator` full flows, tool-call gate, freestyle approval gate, terminal-review gate ordering, bounded review-loop recovery, clarification loop, steering preemption, graph reroute, artifact injection, workspace-scoped tool registry, validation pipeline integration, critique calibration wiring, session rehydration, repo-map reuse, and subagent runner seam.
|
||||||
|
|
||||||
## Ownership
|
## Ownership
|
||||||
|
|
||||||
|
|||||||
@@ -52,14 +52,14 @@ import kotlinx.coroutines.withTimeout
|
|||||||
import kotlinx.coroutines.yield
|
import kotlinx.coroutines.yield
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||||
import org.junit.jupiter.api.Assertions.assertTrue
|
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Integration test for the producer-exit clarification loop: a stage whose produced artifact
|
* Integration test for the producer-exit clarification handoff: a stage whose produced artifact
|
||||||
* carries an unanswered `questions` array parks its run, surfaces a [ClarificationRequestedEvent],
|
* carries an unanswered `questions` array parks its run, surfaces a [ClarificationRequestedEvent],
|
||||||
* and on operator answer re-runs the same stage with the answers in context — without consuming the
|
* and on operator answer the run ADVANCES to the next stage with the answers in context — the
|
||||||
* failure-retry budget (retry maxAttempts is 1 here). Bounded at three rounds.
|
* questioning stage is not re-run (a re-run would restart inference with no prior reasoning and
|
||||||
|
* make the stage re-explore; 2026-07-18). The answers reach later stages as session-wide events.
|
||||||
*/
|
*/
|
||||||
class ClarificationLoopTest {
|
class ClarificationLoopTest {
|
||||||
|
|
||||||
@@ -147,7 +147,7 @@ class ClarificationLoopTest {
|
|||||||
private val config = OrchestrationConfig(retryPolicy = RetryPolicy(maxAttempts = 1, backoffMs = 0))
|
private val config = OrchestrationConfig(retryPolicy = RetryPolicy(maxAttempts = 1, backoffMs = 0))
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `open questions park the stage, the answer re-runs it, then the workflow completes`():
|
fun `open questions park the stage, the answer advances to the next stage`():
|
||||||
Unit = runBlocking {
|
Unit = runBlocking {
|
||||||
val sessionId = SessionId("clarify-happy")
|
val sessionId = SessionId("clarify-happy")
|
||||||
val orchestrator = orchestrator(listOf(withQuestions, clean))
|
val orchestrator = orchestrator(listOf(withQuestions, clean))
|
||||||
@@ -177,14 +177,15 @@ class ClarificationLoopTest {
|
|||||||
)
|
)
|
||||||
assertNotNull(
|
assertNotNull(
|
||||||
events.find { it.payload is WorkflowCompletedEvent },
|
events.find { it.payload is WorkflowCompletedEvent },
|
||||||
"Workflow should complete after the clarification re-run produced a clean analysis",
|
"Workflow should complete once the operator answered and the run advanced",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `clarification rounds are capped so a stuck stage still proceeds`(): Unit = runBlocking {
|
fun `a stage that emits questions parks once and is never re-run`(): Unit = runBlocking {
|
||||||
val sessionId = SessionId("clarify-cap")
|
val sessionId = SessionId("clarify-no-rerun")
|
||||||
// Always returns questions: without a cap this would loop forever.
|
// Provider ALWAYS returns questions. Under the old re-run loop this would park up to the
|
||||||
|
// round cap; under the advance-don't-re-run contract it parks exactly once, then proceeds.
|
||||||
val orchestrator = orchestrator(listOf(withQuestions))
|
val orchestrator = orchestrator(listOf(withQuestions))
|
||||||
val runJob = launch { orchestrator.run(sessionId, graph(), config) }
|
val runJob = launch { orchestrator.run(sessionId, graph(), config) }
|
||||||
|
|
||||||
@@ -207,10 +208,10 @@ class ClarificationLoopTest {
|
|||||||
runJob.join()
|
runJob.join()
|
||||||
|
|
||||||
val rounds = eventStore.read(sessionId).count { it.payload is ClarificationRequestedEvent }
|
val rounds = eventStore.read(sessionId).count { it.payload is ClarificationRequestedEvent }
|
||||||
assertEquals(3, rounds, "Clarification must be capped at three rounds")
|
assertEquals(1, rounds, "Questioning stage must park exactly once (no re-run)")
|
||||||
assertNotNull(
|
assertNotNull(
|
||||||
eventStore.read(sessionId).find { it.payload is WorkflowCompletedEvent },
|
eventStore.read(sessionId).find { it.payload is WorkflowCompletedEvent },
|
||||||
"Workflow should proceed once the clarification cap is reached",
|
"Workflow should advance to completion after the single answered clarification",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import com.correx.core.approvals.model.DomainApprovalRequest
|
|||||||
import com.correx.core.artifacts.DefaultArtifactReducer
|
import com.correx.core.artifacts.DefaultArtifactReducer
|
||||||
import com.correx.core.events.EventDispatcher
|
import com.correx.core.events.EventDispatcher
|
||||||
import com.correx.core.events.events.FailureTicketOpenedEvent
|
import com.correx.core.events.events.FailureTicketOpenedEvent
|
||||||
|
import com.correx.core.events.events.StaticAnalysisCompletedEvent
|
||||||
|
import com.correx.core.events.events.TransitionExecutedEvent
|
||||||
|
import com.correx.core.events.events.WorkflowCompletedEvent
|
||||||
import com.correx.core.events.events.WorkflowFailedEvent
|
import com.correx.core.events.events.WorkflowFailedEvent
|
||||||
import com.correx.core.events.execution.RetryPolicy
|
import com.correx.core.events.execution.RetryPolicy
|
||||||
import com.correx.core.events.types.ProviderId
|
import com.correx.core.events.types.ProviderId
|
||||||
@@ -424,6 +427,7 @@ class RecoveryRoutingTest {
|
|||||||
private fun buildMultiToolOrchestrator(
|
private fun buildMultiToolOrchestrator(
|
||||||
staticOutput: String = "App.tsx: TS2322 type mismatch",
|
staticOutput: String = "App.tsx: TS2322 type mismatch",
|
||||||
provider: InferenceProvider = StageRoutingProvider(),
|
provider: InferenceProvider = StageRoutingProvider(),
|
||||||
|
staticExitCode: Int = 1,
|
||||||
): Pair<DefaultSessionOrchestrator, InMemoryEventStore> {
|
): Pair<DefaultSessionOrchestrator, InMemoryEventStore> {
|
||||||
val eventStore = InMemoryEventStore()
|
val eventStore = InMemoryEventStore()
|
||||||
val shell = ShellTool(allowedExecutables = setOf("true"), workingDir = workspace)
|
val shell = ShellTool(allowedExecutables = setOf("true"), workingDir = workspace)
|
||||||
@@ -471,7 +475,7 @@ class RecoveryRoutingTest {
|
|||||||
workspacePolicy = WorkspacePolicy(workspace),
|
workspacePolicy = WorkspacePolicy(workspace),
|
||||||
// Gate names the file impl wrote, so route-to-owner can resolve the author from the manifest.
|
// Gate names the file impl wrote, so route-to-owner can resolve the author from the manifest.
|
||||||
staticAnalysisRunner = StaticAnalysisRunner { _, _ ->
|
staticAnalysisRunner = StaticAnalysisRunner { _, _ ->
|
||||||
StaticAnalysisRunResult(exitCode = 1, output = staticOutput)
|
StaticAnalysisRunResult(exitCode = staticExitCode, output = staticOutput)
|
||||||
},
|
},
|
||||||
approvalEngine = object : ApprovalEngine {
|
approvalEngine = object : ApprovalEngine {
|
||||||
override fun evaluate(
|
override fun evaluate(
|
||||||
@@ -498,6 +502,84 @@ class RecoveryRoutingTest {
|
|||||||
return orchestrator to eventStore
|
return orchestrator to eventStore
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `non-writing review terminal records static analysis before workflow completion`(): Unit = runBlocking {
|
||||||
|
val (orchestrator, eventStore) = buildMultiToolOrchestrator(
|
||||||
|
staticOutput = "static floor clean",
|
||||||
|
staticExitCode = 0,
|
||||||
|
)
|
||||||
|
val sessionId = SessionId("review-terminal-static-floor")
|
||||||
|
val graph = WorkflowGraph(
|
||||||
|
id = "freestyle-review-terminal",
|
||||||
|
stages = mapOf(
|
||||||
|
StageId("impl") to StageConfig(
|
||||||
|
allowedTools = setOf("file_write"),
|
||||||
|
writeManifest = listOf("App.tsx"),
|
||||||
|
),
|
||||||
|
StageId("review") to StageConfig(
|
||||||
|
allowedTools = setOf("shell"),
|
||||||
|
staticAnalysis = listOf("correx-static-floor -- App.tsx"),
|
||||||
|
autoBuildGate = true,
|
||||||
|
metadata = mapOf("role" to "reviewer"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
transitions = setOf(
|
||||||
|
TransitionEdge(TransitionId("impl->review"), StageId("impl"), StageId("review")) { true },
|
||||||
|
TransitionEdge(TransitionId("review->done"), StageId("review"), StageId("done")) { true },
|
||||||
|
),
|
||||||
|
start = StageId("impl"),
|
||||||
|
)
|
||||||
|
|
||||||
|
orchestrator.run(sessionId, graph, OrchestrationConfig())
|
||||||
|
|
||||||
|
val payloads = eventStore.read(sessionId).map { it.payload }
|
||||||
|
val staticIndex = payloads.indexOfFirst {
|
||||||
|
it is StaticAnalysisCompletedEvent && it.stageId == StageId("review")
|
||||||
|
}
|
||||||
|
val completedIndex = payloads.indexOfFirst { it is WorkflowCompletedEvent }
|
||||||
|
assertTrue(staticIndex >= 0, "the non-writing review terminal must execute its static floor")
|
||||||
|
assertTrue(completedIndex > staticIndex, "static analysis must be durably recorded before completion")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `review loop routes to recovery after exactly three back edges and never emits a fourth`(): Unit =
|
||||||
|
runBlocking {
|
||||||
|
val (orchestrator, eventStore) = buildMultiToolOrchestrator()
|
||||||
|
val sessionId = SessionId("review-loop-convergence")
|
||||||
|
val graph = WorkflowGraph(
|
||||||
|
id = "review-loop-convergence",
|
||||||
|
stages = mapOf(
|
||||||
|
StageId("impl") to StageConfig(allowedTools = setOf("file_write")),
|
||||||
|
StageId("review") to StageConfig(
|
||||||
|
allowedTools = setOf("shell"),
|
||||||
|
metadata = mapOf("role" to "reviewer"),
|
||||||
|
),
|
||||||
|
StageId("recovery") to StageConfig(
|
||||||
|
allowedTools = setOf("shell", "file_write"),
|
||||||
|
metadata = mapOf("role" to "recovery"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
transitions = setOf(
|
||||||
|
TransitionEdge(TransitionId("impl->review"), StageId("impl"), StageId("review")) { true },
|
||||||
|
TransitionEdge(TransitionId("review->impl"), StageId("review"), StageId("impl")) { true },
|
||||||
|
),
|
||||||
|
start = StageId("impl"),
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = orchestrator.run(sessionId, graph, OrchestrationConfig())
|
||||||
|
|
||||||
|
assertTrue(result is WorkflowResult.Failed)
|
||||||
|
val payloads = eventStore.read(sessionId).map { it.payload }
|
||||||
|
val reviewBackEdges = payloads.filterIsInstance<TransitionExecutedEvent>()
|
||||||
|
.filter { it.from == StageId("review") && it.to == StageId("impl") }
|
||||||
|
assertEquals(3, reviewBackEdges.size, "the fourth rejected review must not enter rework")
|
||||||
|
val ticket = payloads.filterIsInstance<FailureTicketOpenedEvent>()
|
||||||
|
.single { it.gate == "review_loop" }
|
||||||
|
assertEquals(StageId("review"), ticket.stageId)
|
||||||
|
assertEquals(StageId("recovery"), ticket.routeTo)
|
||||||
|
assertTrue(payloads.any { it is WorkflowFailedEvent })
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `write-less gate failure routes the ticket to the file's author (route-to-owner)`(): Unit = runBlocking {
|
fun `write-less gate failure routes the ticket to the file's author (route-to-owner)`(): Unit = runBlocking {
|
||||||
val (orchestrator, eventStore) = buildMultiToolOrchestrator()
|
val (orchestrator, eventStore) = buildMultiToolOrchestrator()
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ class LaunchRegistrationRaceTest {
|
|||||||
}.awaitAll()
|
}.awaitAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// computeIfAbsent returns the Job as soon as it's created; the launched body (which
|
||||||
|
// increments launchCount) runs independently on `scope`. Join the registered jobs so the
|
||||||
|
// increments have happened before we assert — otherwise launchCount races the launch bodies.
|
||||||
|
registry.values.forEach { it.join() }
|
||||||
assertEquals(1, launchCount.get(), "mapping lambda must execute exactly once")
|
assertEquals(1, launchCount.get(), "mapping lambda must execute exactly once")
|
||||||
assertEquals(1, registry.size, "registry must contain exactly one entry")
|
assertEquals(1, registry.size, "registry must contain exactly one entry")
|
||||||
scope.cancel()
|
scope.cancel()
|
||||||
@@ -69,6 +73,9 @@ class LaunchRegistrationRaceTest {
|
|||||||
}.awaitAll()
|
}.awaitAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Join the launched bodies before asserting: computeIfAbsent returns the Job immediately,
|
||||||
|
// but the increment runs on `scope` independently and may lag awaitAll (the 49-vs-50 flake).
|
||||||
|
registry.values.forEach { it.join() }
|
||||||
assertEquals(50, launchCount.get(), "each distinct key must execute its lambda")
|
assertEquals(50, launchCount.get(), "each distinct key must execute its lambda")
|
||||||
assertEquals(50, registry.size, "registry must contain 50 entries")
|
assertEquals(50, registry.size, "registry must contain 50 entries")
|
||||||
scope.cancel()
|
scope.cancel()
|
||||||
|
|||||||
Reference in New Issue
Block a user