From ed7efb607243b50ea373e2a60482dd276b8d2b43 Mon Sep 17 00:00:00 2001 From: kami Date: Wed, 15 Jul 2026 23:48:12 +0400 Subject: [PATCH] Implement closed-loop workspace follow-ups --- apps/server/AGENTS.md | 2 + .../kotlin/com/correx/apps/server/Main.kt | 5 + .../com/correx/apps/server/ServerModule.kt | 29 +++-- .../apps/server/git/GitRunBranchTransport.kt | 120 ++++++++++++++++++ .../memory/ArchitectContradictionChecker.kt | 2 +- .../server/memory/L3RepoKnowledgeRetriever.kt | 5 +- .../server/memory/ProjectMemoryService.kt | 10 +- .../server/memory/RepoMapEmbeddingTags.kt | 14 ++ .../apps/server/memory/RepoMapIndexer.kt | 41 +++++- .../server/git/GitRunBranchTransportTest.kt | 60 +++++++++ .../ArchitectContradictionCheckerTest.kt | 2 +- .../memory/L3RepoKnowledgeRetrieverTest.kt | 8 +- .../memory/ProjectMemoryServiceReuseTest.kt | 8 +- .../apps/server/memory/RepoMapIndexerTest.kt | 34 +++++ core/config/AGENTS.md | 2 + .../com/correx/core/config/ConfigLoader.kt | 11 +- .../com/correx/core/config/CorrexConfig.kt | 18 ++- .../correx/core/config/CorrexConfigWriter.kt | 6 + .../correx/core/config/ConfigLoaderTest.kt | 2 +- .../core/config/CorrexConfigWriterTest.kt | 1 + core/events/AGENTS.md | 2 + .../core/events/events/ContractGateEvents.kt | 2 + .../core/events/events/OrchestrationEvents.kt | 13 ++ .../core/events/events/RepoMapEvents.kt | 2 + .../events/serialization/Serialization.kt | 2 + core/kernel/AGENTS.md | 3 + .../DefaultSessionOrchestrator.kt | 7 + .../DefaultSessionOrchestratorRecovery.kt | 7 +- .../DefaultSessionOrchestratorStep.kt | 14 ++ .../SessionOrchestratorExecution.kt | 16 ++- .../orchestration/SessionOrchestratorGates.kt | 1 + .../SessionOrchestratorPreconditions.kt | 44 +++++++ core/transitions/AGENTS.md | 1 + .../core/transitions/graph/StageConfig.kt | 2 +- docs/AGENTS.md | 2 +- docs/qa/QA-stage-prompt-audition.md | 32 +++++ docs/specs/2026-07-15-capability-accretion.md | 12 ++ .../specs/2026-07-15-closed-loop-workspace.md | 18 +++ docs/specs/2026-07-15-stage-prompt-audit.md | 15 +++ examples/AGENTS.md | 1 + .../workflows/prompts/architect_freestyle.md | 4 + infrastructure/inference/AGENTS.md | 1 + .../inference/commons/ModelDescriptor.kt | 2 +- infrastructure/tools/AGENTS.md | 1 + .../tools/filesystem/ListDirTool.kt | 41 +++++- .../tools/filesystem/ListDirToolTest.kt | 20 ++- infrastructure/workflow/AGENTS.md | 1 + .../workflow/ExecutionPlanCompiler.kt | 11 +- .../workflow/ExecutionPlanCompilerTest.kt | 24 ++-- scripts/prompt-audit.sh | 27 ++++ .../src/test/kotlin/RecoveryRoutingTest.kt | 48 +++++++ 51 files changed, 702 insertions(+), 54 deletions(-) create mode 100644 apps/server/src/main/kotlin/com/correx/apps/server/git/GitRunBranchTransport.kt create mode 100644 apps/server/src/main/kotlin/com/correx/apps/server/memory/RepoMapEmbeddingTags.kt create mode 100644 apps/server/src/test/kotlin/com/correx/apps/server/git/GitRunBranchTransportTest.kt create mode 100644 core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorPreconditions.kt create mode 100644 docs/qa/QA-stage-prompt-audition.md create mode 100644 docs/specs/2026-07-15-capability-accretion.md create mode 100644 docs/specs/2026-07-15-closed-loop-workspace.md create mode 100644 docs/specs/2026-07-15-stage-prompt-audit.md create mode 100755 scripts/prompt-audit.sh diff --git a/apps/server/AGENTS.md b/apps/server/AGENTS.md index 23af31ca..eaa8dcd6 100644 --- a/apps/server/AGENTS.md +++ b/apps/server/AGENTS.md @@ -19,6 +19,8 @@ All sources under `apps/server/src/`. - `GET /health` — health report (probes: event-store, llama-server, disk watermark) - `GET /stats` — metrics report (MetricsProjection) - `GET /metrics/tool-reliability` — per-model tool-call validity across the event log (`ToolReliabilityInspectionService`); groundwork for capability-aware routing +- Optional `[git]` transport creates `run/` 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. ### 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. diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt index fcad7b22..1a3ba9e0 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt @@ -654,6 +654,11 @@ fun main() { taskArtifactResolver = taskArtifactResolver, taskSessionResolver = taskSessionResolver, gitCommitReader = gitCommitReader, + gitRunBranchTransport = if (correxConfig.git.enabled) { + com.correx.apps.server.git.GitRunBranchTransport(eventStore, correxConfig.git) + } else { + null + }, ) // Wire live config editing: persist to TOML, swap the holder, and rebuild config-derived // services. Built after the module so the rebuild hook can swap them in place. diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt b/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt index 2cf57c04..f27e638c 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt @@ -133,6 +133,9 @@ class ServerModule( // Reads recent commits for POST /tasks/sync-git (git-driven status). Null disables the repo read // (the endpoint then only acts on commits supplied in the request body). val gitCommitReader: com.correx.core.tasks.GitCommitReader? = null, + // Optional plain-Git transport for a server-owned checkout. It creates and pushes a per-run + // branch; null preserves the ordinary local-workspace lifecycle. + private val gitRunBranchTransport: com.correx.apps.server.git.GitRunBranchTransport? = null, ) { val approvalCoordinator: ApprovalCoordinator = approvalCoordinator ?: ApprovalCoordinator( orchestrator = orchestrator, @@ -409,17 +412,25 @@ class ServerModule( bindProjectProfile(sessionId) bindAgentInstructions(sessionId) runCatching { - val result = orchestrator.run(sessionId, graph, sessionConfig) - freestyleHandoff(sessionId, graph, result) - // Distil this run's decisions into durable project memory on completion. - projectMemory?.let { pm -> pm.persist(sessionId, pm.repoRoot()) } - // Propose learned profile adaptations based on session journal (opt-in, never auto-applied). - operatorProfile?.let { profile -> - profileAdaptationService?.let { svc -> - runCatching { svc.proposeAdaptation(sessionId, profile) } - .onFailure { log.warn("Profile adaptation failed: {}", it.message) } + suspend fun runAndFinalize() { + val result = orchestrator.run(sessionId, graph, sessionConfig) + freestyleHandoff(sessionId, graph, result) + // Distil this run's decisions into durable project memory on completion. + projectMemory?.let { pm -> pm.persist(sessionId, pm.repoRoot()) } + // Propose learned profile adaptations based on session journal (opt-in, never auto-applied). + operatorProfile?.let { profile -> + profileAdaptationService?.let { svc -> + runCatching { svc.proposeAdaptation(sessionId, profile) } + .onFailure { log.warn("Profile adaptation failed: {}", it.message) } + } } } + val workspaceRoot = sessionConfig.workspace?.workspaceRoot + if (gitRunBranchTransport != null && workspaceRoot != null) { + gitRunBranchTransport.onRunBranch(sessionId, workspaceRoot) { runAndFinalize() } + } else { + runAndFinalize() + } }.onFailure { ex -> recordUnhandledFailure(sessionId, graph, sessionConfig, ex) } activeSessionJobs.remove(sessionId) } diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/git/GitRunBranchTransport.kt b/apps/server/src/main/kotlin/com/correx/apps/server/git/GitRunBranchTransport.kt new file mode 100644 index 00000000..731613eb --- /dev/null +++ b/apps/server/src/main/kotlin/com/correx/apps/server/git/GitRunBranchTransport.kt @@ -0,0 +1,120 @@ +package com.correx.apps.server.git + +import com.correx.core.config.GitConfig +import com.correx.core.events.events.EventMetadata +import com.correx.core.events.events.NewEvent +import com.correx.core.events.events.RunBranchPushedEvent +import com.correx.core.events.events.WorkflowCompletedEvent +import com.correx.core.events.events.WorkflowFailedEvent +import com.correx.core.events.stores.EventStore +import com.correx.core.events.types.EventId +import com.correx.core.events.types.SessionId +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.datetime.Clock +import java.nio.file.Path +import java.util.UUID +import java.util.concurrent.TimeUnit + +private const val GIT_TIMEOUT_SECONDS = 30L + +/** + * Plain-Git transport for a server-owned checkout. The mutex intentionally spans a whole run: + * checkout is process-global state, so parallel sessions cannot safely share one working tree. + */ +class GitRunBranchTransport( + private val eventStore: EventStore, + private val config: GitConfig, +) { + private val checkoutLock = Mutex() + + suspend fun onRunBranch( + sessionId: SessionId, + workspaceRoot: Path, + block: suspend () -> T, + ): T = checkoutLock.withLock { + val prepared = prepare(sessionId, workspaceRoot) + try { + block() + } finally { + val terminalStage = eventStore.read(sessionId).lastOrNull { event -> + event.payload is WorkflowCompletedEvent || event.payload is WorkflowFailedEvent + }?.payload?.let { payload -> + when (payload) { + is WorkflowCompletedEvent -> payload.terminalStageId.value + is WorkflowFailedEvent -> payload.stageId.value + else -> null + } + } ?: "unknown" + pushTerminalBranch(sessionId, workspaceRoot, prepared, terminalStage) + } + } + + private suspend fun prepare(sessionId: SessionId, workspaceRoot: Path): PreparedBranch = withContext(Dispatchers.IO) { + val branch = "run/${sessionId.value}" + runGit(workspaceRoot, "fetch", config.remote, config.baseBranch) + val baseRef = "${config.remote}/${config.baseBranch}" + val baseSha = runGit(workspaceRoot, "rev-parse", baseRef).trim() + runGit(workspaceRoot, "checkout", "-B", branch, baseRef) + PreparedBranch(branch, baseSha) + } + + private suspend fun pushTerminalBranch( + sessionId: SessionId, + workspaceRoot: Path, + prepared: PreparedBranch, + terminalStage: String, + ) = + withContext(Dispatchers.IO) { + runGit(workspaceRoot, "add", "-A") + if (runGitExitCode(workspaceRoot, "diff", "--cached", "--quiet") != 0) { + val args = mutableListOf("commit", "-m", "correx run ${sessionId.value} terminal $terminalStage") + if (config.author.isNotBlank()) args += "--author=${config.author}" + runGit(workspaceRoot, *args.toTypedArray()) + } + val headSha = runGit(workspaceRoot, "rev-parse", "HEAD").trim() + runGit(workspaceRoot, "push", config.remote, "${prepared.branch}:${prepared.branch}") + eventStore.append( + NewEvent( + metadata = EventMetadata( + eventId = EventId(UUID.randomUUID().toString()), + sessionId = sessionId, + timestamp = Clock.System.now(), + schemaVersion = 1, + causationId = null, + correlationId = null, + ), + payload = RunBranchPushedEvent(sessionId, prepared.branch, prepared.baseSha, headSha), + ), + ) + } + + private fun runGit(root: Path, vararg args: String): String { + val process = ProcessBuilder(listOf("git", "-C", root.toString()) + args) + .redirectErrorStream(true) + .start() + val output = process.inputStream.bufferedReader().use { it.readText() } + if (!process.waitFor(GIT_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + process.destroyForcibly() + error("git ${args.joinToString(" ")} timed out") + } + check(process.exitValue() == 0) { "git ${args.joinToString(" ")} failed: ${output.trim()}" } + return output + } + + private fun runGitExitCode(root: Path, vararg args: String): Int { + val process = ProcessBuilder(listOf("git", "-C", root.toString()) + args) + .redirectErrorStream(true) + .start() + process.inputStream.bufferedReader().use { it.readText() } + if (!process.waitFor(GIT_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + process.destroyForcibly() + error("git ${args.joinToString(" ")} timed out") + } + return process.exitValue() + } + + private data class PreparedBranch(val branch: String, val baseSha: String) +} diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/memory/ArchitectContradictionChecker.kt b/apps/server/src/main/kotlin/com/correx/apps/server/memory/ArchitectContradictionChecker.kt index 76d72f90..3d1e4dfe 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/memory/ArchitectContradictionChecker.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/memory/ArchitectContradictionChecker.kt @@ -19,7 +19,7 @@ import com.correx.core.talkie.l3.L3Query * [ProjectMemoryService] under `turnId = "project:"` (trailing-`:` delimiter). This is * the only decision-bearing L3 namespace that exists today, so [decisionNamespacePrefix] defaults * to `"project:"` — a `startsWith` prefix, matching the trailing-`:` delimiter convention used by - * [L3RepoKnowledgeRetriever]'s `"repomap::"` filter. Hits are also constrained to PRIOR + * [L3RepoKnowledgeRetriever]'s versioned `"repomap:v2::"` filter. Hits are also constrained to PRIOR * sessions (`entry.sessionId != sessionId`) so the architect never flags its own in-flight run. */ class ArchitectContradictionChecker( diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/memory/L3RepoKnowledgeRetriever.kt b/apps/server/src/main/kotlin/com/correx/apps/server/memory/L3RepoKnowledgeRetriever.kt index 42759b4f..3e50044a 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/memory/L3RepoKnowledgeRetriever.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/memory/L3RepoKnowledgeRetriever.kt @@ -35,8 +35,9 @@ class L3RepoKnowledgeRetriever( override suspend fun retrieve(sessionId: SessionId, query: String, k: Int): List { val vector = embedder.embed(query) val candidates = l3MemoryStore.query(L3Query(vector = vector, k = k * RETRIEVAL_OVERSAMPLE_FACTOR)) - // Trailing ':' so "/repo" does not also match "/repo2" turnIds (prefix collision). - .filter { it.entry.turnId.startsWith("repomap:$repoRoot:") } + // Versioned trailing delimiter keeps sibling repo roots isolated and stale semantic + // documents out after a repo-map descriptor format change. + .filter { it.entry.turnId.startsWith(repoMapEmbeddingPrefix(repoRoot)) } val (kept, dropped) = candidates.partition { it.score >= MIN_SIMILARITY_SCORE } if (dropped.isNotEmpty()) recordDropped(sessionId, query, dropped.map { it.toHit() }) return kept.take(k).map { it.toHit() } diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/memory/ProjectMemoryService.kt b/apps/server/src/main/kotlin/com/correx/apps/server/memory/ProjectMemoryService.kt index 924ab20f..1957374d 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/memory/ProjectMemoryService.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/memory/ProjectMemoryService.kt @@ -110,13 +110,13 @@ class ProjectMemoryService( stateKey: String?, entries: List, ) { - if (stateKey != null && l3MemoryStore.existsByTurnIdPrefix("repomap:$repoRoot:$stateKey")) { + val tag = repoMapEmbeddingTag(repoRoot, stateKey) + if (stateKey != null && l3MemoryStore.existsByTurnIdPrefix(tag)) { log.debug("repo-map already embedded for {}@{} — skipping L3 store", repoRoot, stateKey) return } // Trailing ':' delimiter so a repoRoot prefix can't collide with a longer sibling // (/repo vs /repo2) under the retriever's startsWith filter. - val tag = if (stateKey != null) "repomap:$repoRoot:$stateKey" else "repomap:$repoRoot:" // Docs are already surfaced via the "Docs available" catalog built straight from // RepoMapComputedEvent (SessionOrchestrator, 2026-07-07 doc-injection rework) — embedding // them into the same L3 namespace as code let generic textual similarity (e.g. "analyst", @@ -126,7 +126,11 @@ class ProjectMemoryService( // rather than the repo-map floor that was fixed then). entries.filterNot { it.path.endsWith(".md", ignoreCase = true) }.forEach { entry -> runCatching { - val text = entry.path + if (entry.symbols.isEmpty()) "" else ": ${entry.symbols.joinToString(", ")}" + val text = buildString { + append(entry.path) + if (entry.descriptor.isNotBlank()) append(": ").append(entry.descriptor) + if (entry.symbols.isNotEmpty()) append("; symbols: ").append(entry.symbols.joinToString(", ")) + } l3MemoryStore.store( L3MemoryEntry( id = UUID.randomUUID().toString(), diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/memory/RepoMapEmbeddingTags.kt b/apps/server/src/main/kotlin/com/correx/apps/server/memory/RepoMapEmbeddingTags.kt new file mode 100644 index 00000000..1d04694a --- /dev/null +++ b/apps/server/src/main/kotlin/com/correx/apps/server/memory/RepoMapEmbeddingTags.kt @@ -0,0 +1,14 @@ +package com.correx.apps.server.memory + +/** + * Version the serialized repo-map embedding namespace. A descriptor change alters the semantic + * document, so a new version deliberately ignores stale vectors and causes one re-embed per + * recorded workspace state. The recorded repo-map event remains backwards compatible. + */ +internal const val REPO_MAP_EMBEDDING_VERSION = "v2" + +internal fun repoMapEmbeddingPrefix(repoRoot: String): String = + "repomap:$REPO_MAP_EMBEDDING_VERSION:$repoRoot:" + +internal fun repoMapEmbeddingTag(repoRoot: String, stateKey: String?): String = + stateKey?.let { repoMapEmbeddingPrefix(repoRoot) + it } ?: repoMapEmbeddingPrefix(repoRoot) diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/memory/RepoMapIndexer.kt b/apps/server/src/main/kotlin/com/correx/apps/server/memory/RepoMapIndexer.kt index 79ab3d44..73178072 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/memory/RepoMapIndexer.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/memory/RepoMapIndexer.kt @@ -20,7 +20,8 @@ interface RepoMapIndexerPort { * Walks a repo and produces a ranked file/symbol index. Reads the filesystem (a * nondeterministic environment observation) — the caller records the result as a * [com.correx.core.events.events.RepoMapComputedEvent] so replay reads recorded facts and - * never re-scans (invariant #9). Paths + top-level symbol names only, never file bodies. + * never re-scans (invariant #9). Entries retain paths, top-level symbols, and a bounded, + * deterministic purpose descriptor; full file bodies are never stored in the repo map. * * Scoring is recency-based: the most-recently-modified source file scores 1.0, the oldest * ~0.0, linearly between. Recency is a cheap centrality proxy — files under active work rank @@ -53,6 +54,7 @@ class RepoMapIndexer( path = path.relativeTo(repoRoot).toString(), score = (mtimes.getValue(path) - min).toDouble() / span, symbols = extractSymbols(path), + descriptor = sourceDescriptor(path), ) } .sortedByDescending { it.score } @@ -104,6 +106,35 @@ class RepoMapIndexer( } ?: emptyList() } + /** + * A small semantic bridge between an intent ("context packing") and code whose class name + * alone lacks those words. Package/module identity, leading KDoc/comments, and imports are + * stable source facts; they are capped before the event and L3 embedding are recorded. + */ + private fun sourceDescriptor(path: Path): String { + if (path.extension.equals("md", ignoreCase = true)) return docDescriptor(path).orEmpty() + val lines = runCatching { path.readText().lineSequence().take(DESCRIPTOR_SCAN_LINES).toList() } + .getOrDefault(emptyList()) + if (lines.isEmpty()) return "" + val packageOrModule = lines.firstNotNullOfOrNull { line -> + PACKAGE_OR_MODULE.find(line.trim())?.groupValues?.get(1) + } + val imports = lines.asSequence() + .mapNotNull { IMPORT.find(it.trim())?.groupValues?.get(1) } + .take(MAX_DESCRIPTOR_IMPORTS) + .toList() + val comment = lines.asSequence() + .map { raw -> raw.trim() } + .filter(::isCommentLine) + .map { it.removePrefix("/**").removePrefix("*").removePrefix("//").removePrefix("#").trim() } + .firstOrNull { it.length >= MIN_COMMENT_CHARS } + return buildList { + packageOrModule?.let { add("module $it") } + if (imports.isNotEmpty()) add("uses ${imports.joinToString(", ")}") + comment?.let { add(it) } + }.joinToString("; ").truncateDescriptor() + } + /** * One-line "what is this doc about" descriptor, preferred in order: YAML frontmatter * `description`/`summary`/`title`, then the first `# H1` heading, then the first prose line. @@ -133,13 +164,21 @@ class RepoMapIndexer( private fun String.truncateDescriptor(): String = if (length <= MAX_DESCRIPTOR_CHARS) this else take(MAX_DESCRIPTOR_CHARS).trimEnd() + "…" + private fun isCommentLine(line: String): Boolean = + line.startsWith("/**") || line.startsWith("*") || line.startsWith("//") || line.startsWith("#") + companion object { /** File extensions considered for indexing — reused by [WorkspaceStateProbe] for fingerprinting. */ val INDEXED_EXTENSIONS: Set get() = SYMBOL_PATTERNS.keys private const val MAX_SYMBOLS_PER_FILE = 40 private const val MAX_DESCRIPTOR_CHARS = 120 + private const val DESCRIPTOR_SCAN_LINES = 80 + private const val MAX_DESCRIPTOR_IMPORTS = 6 + private const val MIN_COMMENT_CHARS = 16 private val FRONTMATTER_KEYS = listOf("description", "summary", "title") + private val PACKAGE_OR_MODULE = Regex("""(?:package|module|namespace)\s+([A-Za-z0-9_.$/-]+)""") + private val IMPORT = Regex("""(?:import|using)\s+([A-Za-z0-9_.$/*-]+)""") // Top-level declarations only — best-effort per language. Bodies/locals are never matched. val SYMBOL_PATTERNS: Map = mapOf( diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/git/GitRunBranchTransportTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/git/GitRunBranchTransportTest.kt new file mode 100644 index 00000000..067663a2 --- /dev/null +++ b/apps/server/src/test/kotlin/com/correx/apps/server/git/GitRunBranchTransportTest.kt @@ -0,0 +1,60 @@ +package com.correx.apps.server.git + +import com.correx.core.config.GitConfig +import com.correx.core.events.events.RunBranchPushedEvent +import com.correx.core.events.types.SessionId +import com.correx.infrastructure.persistence.InMemoryEventStore +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Test +import java.nio.file.Files +import java.nio.file.Path +import java.util.concurrent.TimeUnit +import kotlin.io.path.writeText +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class GitRunBranchTransportTest { + + @Test + fun `creates a run branch, pushes it, and records the pushed ref`(): Unit = runBlocking { + val root = Files.createTempDirectory("correx-git-transport") + val remote = Files.createTempDirectory("correx-git-remote") + git(root, "init", "-b", "main") + git(root, "config", "user.name", "Test User") + git(root, "config", "user.email", "test@example.test") + root.resolve("README.md").writeText("base\n") + git(root, "add", "README.md") + git(root, "commit", "-m", "base") + git(remote, "init", "--bare") + git(root, "remote", "add", "origin", remote.toString()) + git(root, "push", "origin", "main") + + val store = InMemoryEventStore() + val sessionId = SessionId("git-transport") + val transport = GitRunBranchTransport( + store, + GitConfig(enabled = true, remote = "origin", baseBranch = "main"), + ) + + transport.onRunBranch(sessionId, root) { + root.resolve("agent-output.txt").writeText("generated\n") + } + + assertEquals("run/git-transport", git(root, "branch", "--show-current").trim()) + assertTrue(git(root, "ls-remote", "--heads", "origin", "run/git-transport").isNotBlank()) + val pushed = store.read(sessionId).single().payload as RunBranchPushedEvent + assertEquals("run/git-transport", pushed.branch) + assertTrue(pushed.baseSha.isNotBlank()) + assertEquals(git(root, "rev-parse", "HEAD").trim(), pushed.headSha) + } + + private fun git(root: Path, vararg args: String): String { + val process = ProcessBuilder(listOf("git", "-C", root.toString()) + args) + .redirectErrorStream(true) + .start() + val output = process.inputStream.bufferedReader().use { it.readText() } + check(process.waitFor(20, TimeUnit.SECONDS)) { "git ${args.joinToString(" ")} timed out" } + check(process.exitValue() == 0) { "git ${args.joinToString(" ")} failed: $output" } + return output + } +} diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/memory/ArchitectContradictionCheckerTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/memory/ArchitectContradictionCheckerTest.kt index 18ec8df6..163b79e7 100644 --- a/apps/server/src/test/kotlin/com/correx/apps/server/memory/ArchitectContradictionCheckerTest.kt +++ b/apps/server/src/test/kotlin/com/correx/apps/server/memory/ArchitectContradictionCheckerTest.kt @@ -87,7 +87,7 @@ class ArchitectContradictionCheckerTest { val store = CannedL3MemoryStore( listOf( // Above threshold but a repo-map entry, not a decision — must be excluded. - hit("Foo.kt: ClassA, funcB", score = 0.95f, turnId = "repomap:/repo:abc"), + hit("Foo.kt: ClassA, funcB", score = 0.95f, turnId = "repomap:v2:/repo:abc"), ), ) val checker = ArchitectContradictionChecker(ContradictionOnesEmbedder(), store) diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/memory/L3RepoKnowledgeRetrieverTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/memory/L3RepoKnowledgeRetrieverTest.kt index 6d82f7e7..19c4e416 100644 --- a/apps/server/src/test/kotlin/com/correx/apps/server/memory/L3RepoKnowledgeRetrieverTest.kt +++ b/apps/server/src/test/kotlin/com/correx/apps/server/memory/L3RepoKnowledgeRetrieverTest.kt @@ -19,8 +19,8 @@ class L3RepoKnowledgeRetrieverTest { @Test fun `retriever for a repoRoot does not match a sibling whose path it prefixes`(): Unit = runBlocking { val l3 = InMemoryL3MemoryStore() - l3.store(L3MemoryEntry("1", SessionId("s"), "repomap:/repo:git:h", "repo/A.kt: Foo", vec, 0L)) - l3.store(L3MemoryEntry("2", SessionId("s"), "repomap:/repo2:git:h", "repo2/B.kt: Bar", vec, 0L)) + l3.store(L3MemoryEntry("1", SessionId("s"), "repomap:v2:/repo:git:h", "repo/A.kt: Foo", vec, 0L)) + l3.store(L3MemoryEntry("2", SessionId("s"), "repomap:v2:/repo2:git:h", "repo2/B.kt: Bar", vec, 0L)) val hits = L3RepoKnowledgeRetriever(RetrieverConstantEmbedder(), l3, "/repo") .retrieve(SessionId("s"), "anything", 10) @@ -31,8 +31,8 @@ class L3RepoKnowledgeRetrieverTest { @Test fun `retriever matches its own repoRoot entries with or without a stateKey suffix`(): Unit = runBlocking { val l3 = InMemoryL3MemoryStore() - l3.store(L3MemoryEntry("1", SessionId("s"), "repomap:/repo:git:h1", "repo/A.kt", vec, 0L)) - l3.store(L3MemoryEntry("2", SessionId("s"), "repomap:/repo:", "repo/B.kt", vec, 0L)) + l3.store(L3MemoryEntry("1", SessionId("s"), "repomap:v2:/repo:git:h1", "repo/A.kt", vec, 0L)) + l3.store(L3MemoryEntry("2", SessionId("s"), "repomap:v2:/repo:", "repo/B.kt", vec, 0L)) val hits = L3RepoKnowledgeRetriever(RetrieverConstantEmbedder(), l3, "/repo") .retrieve(SessionId("s"), "anything", 10) diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/memory/ProjectMemoryServiceReuseTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/memory/ProjectMemoryServiceReuseTest.kt index fd478d53..3f3e58c4 100644 --- a/apps/server/src/test/kotlin/com/correx/apps/server/memory/ProjectMemoryServiceReuseTest.kt +++ b/apps/server/src/test/kotlin/com/correx/apps/server/memory/ProjectMemoryServiceReuseTest.kt @@ -118,7 +118,7 @@ class ProjectMemoryServiceReuseTest { svc.indexAndRecord(sessionId, "/repo") assertTrue( - l3.existsByTurnIdPrefix("repomap:/repo:git:hash1"), + l3.existsByTurnIdPrefix("repomap:v2:/repo:git:hash1"), "entries should be embedded with stateKey tag", ) } @@ -131,7 +131,7 @@ class ProjectMemoryServiceReuseTest { val embedder = ConstantEmbedderReuse() // Index /repo and /repo2 into the same shared L3 store with the same stateKey. - // The trailing-':' delimiter on the repomap: tag must keep their entries from bleeding + // The versioned trailing-':' delimiter on the repomap tag must keep their entries from bleeding // across roots: "/repo" must NOT match tags written for "/repo2". val repoEntries = listOf(RepoMapEntry(path = "src/Repo.kt", score = 1.0, symbols = listOf("RepoClass"))) val repo2Entries = listOf(RepoMapEntry(path = "src/Repo2.kt", score = 1.0, symbols = listOf("Repo2Class"))) @@ -142,8 +142,8 @@ class ProjectMemoryServiceReuseTest { .indexAndRecord(SessionId("session-collide-repo2"), "/repo2") // existsByTurnIdPrefix with the delimiter must not match the other root. - assertTrue(l3.existsByTurnIdPrefix("repomap:/repo:"), "tag for /repo should exist") - assertTrue(l3.existsByTurnIdPrefix("repomap:/repo2:"), "tag for /repo2 should exist") + assertTrue(l3.existsByTurnIdPrefix("repomap:v2:/repo:"), "tag for /repo should exist") + assertTrue(l3.existsByTurnIdPrefix("repomap:v2:/repo2:"), "tag for /repo2 should exist") // The retriever scoped to /repo must return only /repo's entry, never /repo2's. val hits = L3RepoKnowledgeRetriever(embedder = embedder, l3MemoryStore = l3, repoRoot = "/repo") diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/memory/RepoMapIndexerTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/memory/RepoMapIndexerTest.kt index d471ebe5..a2a553ff 100644 --- a/apps/server/src/test/kotlin/com/correx/apps/server/memory/RepoMapIndexerTest.kt +++ b/apps/server/src/test/kotlin/com/correx/apps/server/memory/RepoMapIndexerTest.kt @@ -41,6 +41,40 @@ class RepoMapIndexerTest { assertTrue(entries.first { it.path == "Old.kt" }.symbols.contains("Old")) } + @Test + fun `source descriptor retains package imports and leading purpose comment`(@TempDir root: Path) { + root.resolve("src").createDirectories() + root.resolve("src/Context.kt").writeText( + """ + /** Builds the context packing pipeline for stage inference. */ + package com.correx.context + import com.correx.events.EventStore + class DefaultContextPackBuilder + """.trimIndent(), + ) + + val entry = RepoMapIndexer().index(root).single() + + assertTrue(entry.descriptor.contains("module com.correx.context")) + assertTrue(entry.descriptor.contains("EventStore")) + assertTrue(entry.descriptor.contains("context packing pipeline")) + } + + @Test + fun `source descriptor never uses a declaration as a purpose comment`(@TempDir root: Path) { + root.resolve("Plain.kt").writeText( + """ + package com.correx.example + import com.correx.events.EventStore + class ALongEnoughDeclarationToHaveLookedLikeAComment + """.trimIndent(), + ) + + val entry = RepoMapIndexer().index(root).single() + + assertFalse(entry.descriptor.contains("class ALongEnough"), entry.descriptor) + } + @Test fun `extracts GDScript top-level symbols`(@TempDir root: Path) { root.resolve("player.gd").writeText( diff --git a/core/config/AGENTS.md b/core/config/AGENTS.md index 88e95a31..3db5a65c 100644 --- a/core/config/AGENTS.md +++ b/core/config/AGENTS.md @@ -23,6 +23,8 @@ CORREX kernel team. Config schema changes affect all consumers — coordinate wi - Config is read from disk; it is not event-sourced. Do not add event/state/reducer structures here. - `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. +- `[git]` is opt-in server transport configuration (`enabled`, `remote`, `base_branch`, optional `author`); it is off by default. +- `ModelConfig.contextSize` defaults to 24,576 tokens; explicit `context_size` remains the operator override for smaller local-model windows. ## Verification diff --git a/core/config/src/main/kotlin/com/correx/core/config/ConfigLoader.kt b/core/config/src/main/kotlin/com/correx/core/config/ConfigLoader.kt index e2e03d4a..4864508a 100644 --- a/core/config/src/main/kotlin/com/correx/core/config/ConfigLoader.kt +++ b/core/config/src/main/kotlin/com/correx/core/config/ConfigLoader.kt @@ -199,7 +199,7 @@ object ConfigLoader { private const val DEFAULT_CONVERSATION_KEEP_LAST = 6 private const val DEFAULT_RETRIEVAL_K = 5 private const val DEFAULT_TOKEN_BUDGET = 4096 - private const val DEFAULT_MODEL_CONTEXT_SIZE = 8192 + private const val DEFAULT_MODEL_CONTEXT_SIZE = 24_576 private const val DEFAULT_PROJECT_MEMORY_K = 5 private const val DEFAULT_PROJECT_MAX_DEPTH = 4 private const val DEFAULT_PROJECT_INJECT_TOP_K = 30 @@ -735,6 +735,14 @@ object ConfigLoader { repeatPenalty = samplingSection["repeat_penalty"]?.let { asDouble(it) }, ) + val gitSection = sections["git"] ?: emptyMap() + val git = GitConfig( + enabled = asBoolean(gitSection["enabled"], false), + remote = asString(gitSection["remote"], "origin"), + baseBranch = asString(gitSection["base_branch"], "main"), + author = asString(gitSection["author"], ""), + ) + return CorrexConfig( server = server, tui = tui, @@ -749,6 +757,7 @@ object ConfigLoader { personalization = personalization, orchestration = orchestration, sampling = sampling, + git = git, ) } diff --git a/core/config/src/main/kotlin/com/correx/core/config/CorrexConfig.kt b/core/config/src/main/kotlin/com/correx/core/config/CorrexConfig.kt index a53d85c6..99a7819d 100644 --- a/core/config/src/main/kotlin/com/correx/core/config/CorrexConfig.kt +++ b/core/config/src/main/kotlin/com/correx/core/config/CorrexConfig.kt @@ -18,6 +18,21 @@ data class CorrexConfig( val orchestration: OrchestrationKnobs = OrchestrationKnobs(), val sampling: SamplingConfig = SamplingConfig(), val health: HealthConfig = HealthConfig(), + val git: GitConfig = GitConfig(), +) + +/** + * Optional transport for a server-owned checkout. Each run executes on a `run/` branch + * based on [baseBranch] and pushes that branch on terminal completion; the working directory stays + * a real local path, never a remote URL or mounted client filesystem. + */ +@Serializable +data class GitConfig( + val enabled: Boolean = false, + val remote: String = "origin", + val baseBranch: String = "main", + /** Optional Git author value, for example `Correx `. */ + val author: String = "", ) /** @@ -283,7 +298,8 @@ data class L3Config( data class ModelConfig( val id: String, val modelPath: String, - val contextSize: Int = 8192, + /** Default window for implementation stages; leave headroom above their 24K prompt budget. */ + val contextSize: Int = 24_576, val params: Map = emptyMap(), val capabilities: Map = emptyMap(), ) diff --git a/core/config/src/main/kotlin/com/correx/core/config/CorrexConfigWriter.kt b/core/config/src/main/kotlin/com/correx/core/config/CorrexConfigWriter.kt index e24b676e..7fec7c78 100644 --- a/core/config/src/main/kotlin/com/correx/core/config/CorrexConfigWriter.kt +++ b/core/config/src/main/kotlin/com/correx/core/config/CorrexConfigWriter.kt @@ -109,6 +109,12 @@ object CorrexConfigWriter { cfg.sampling.minP?.let { b.kv("min_p", it) } cfg.sampling.repeatPenalty?.let { b.kv("repeat_penalty", it) } + b.section("git") + b.kv("enabled", cfg.git.enabled) + b.kv("remote", str(cfg.git.remote)) + b.kv("base_branch", str(cfg.git.baseBranch)) + b.kv("author", str(cfg.git.author)) + b.section("personalization") b.kv("enabled", cfg.personalization.enabled) b.kv("learn", cfg.personalization.learn) diff --git a/core/config/src/test/kotlin/com/correx/core/config/ConfigLoaderTest.kt b/core/config/src/test/kotlin/com/correx/core/config/ConfigLoaderTest.kt index 305c0724..78a0e794 100644 --- a/core/config/src/test/kotlin/com/correx/core/config/ConfigLoaderTest.kt +++ b/core/config/src/test/kotlin/com/correx/core/config/ConfigLoaderTest.kt @@ -434,7 +434,7 @@ class ConfigLoaderTest { assertEquals(1, result.models.size) assertEquals("local-model", result.models[0].id) assertEquals("/models/local.gguf", result.models[0].modelPath) - assertEquals(8192, result.models[0].contextSize) + assertEquals(24_576, result.models[0].contextSize) } @Test diff --git a/core/config/src/test/kotlin/com/correx/core/config/CorrexConfigWriterTest.kt b/core/config/src/test/kotlin/com/correx/core/config/CorrexConfigWriterTest.kt index c89d57e0..048d074d 100644 --- a/core/config/src/test/kotlin/com/correx/core/config/CorrexConfigWriterTest.kt +++ b/core/config/src/test/kotlin/com/correx/core/config/CorrexConfigWriterTest.kt @@ -37,6 +37,7 @@ class CorrexConfigWriterTest { ), personalization = PersonalizationConfig(enabled = true, learn = true), project = ProjectConfig(enabled = true, root = "/repo", memoryK = 8, maxDepth = 6, injectTopK = 40), + git = GitConfig(enabled = true, remote = "gitea", baseBranch = "develop", author = "Correx "), modelsSettings = ModelsSettings(defaultModel = "m1", host = "0.0.0.0", port = 10001), orchestration = OrchestrationKnobs(stageTimeoutMs = 90_000, journalCompactionTokenThreshold = 12_000), providers = listOf( diff --git a/core/events/AGENTS.md b/core/events/AGENTS.md index 45ed540d..3a1466b1 100644 --- a/core/events/AGENTS.md +++ b/core/events/AGENTS.md @@ -27,6 +27,8 @@ CORREX kernel team. This is the most cross-cutting module in the codebase — ch - **SILENT FAILURE TRAP**: After adding any `EventPayload` subclass, immediately add it to `Serialization.kt` `eventModule` block. Run `./gradlew check` to verify. Tests may pass without it but runtime replay will fail silently. - `AnyMapSerializer` — custom serializer for `Map`; use it for dynamic payloads, don't roll another. - Event classes are `@Serializable data class` with no mutable state. No methods beyond data accessors. +- `RunBranchPushedEvent` records an optional server Git transport push only after it succeeds; its branch/base/head SHAs are observations, not values replay recalculates. +- `RepoMapEntry.descriptor` is a bounded source-purpose observation recorded with the repo map and used when constructing semantic L3 embeddings. - Do not add domain logic to events. They are records, not actors. - `EgressAllowlistProjection` — special projection kept in this module because it is used by both `core:toolintent` and `core:events` consumers; it is a shared cross-cutting projection. diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/ContractGateEvents.kt b/core/events/src/main/kotlin/com/correx/core/events/events/ContractGateEvents.kt index 25c7498a..74353e9a 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/events/ContractGateEvents.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/events/ContractGateEvents.kt @@ -16,6 +16,8 @@ data class ContractAssertionResult( /** Evaluator that produced the verdict: FS / TEXT / COMPILER / AST. */ val evaluator: String, val passed: Boolean, + /** True when this assertion is intentionally deferred to another authoritative gate. */ + val skipped: Boolean = false, /** Concrete reason the assertion failed (or a confirmation when it passed). Kept bounded. */ val evidence: String, ) diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/OrchestrationEvents.kt b/core/events/src/main/kotlin/com/correx/core/events/events/OrchestrationEvents.kt index 9f9e924c..135b3591 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/events/OrchestrationEvents.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/events/OrchestrationEvents.kt @@ -36,6 +36,19 @@ data class WorkflowFailedEvent( val retryExhausted: Boolean, ) : EventPayload +/** + * Observation recorded only after the optional server Git transport has pushed a terminal run + * branch. This keeps the reviewable branch reference replayable without re-running Git. + */ +@Serializable +@SerialName("RunBranchPushed") +data class RunBranchPushedEvent( + val sessionId: SessionId, + val branch: String, + val baseSha: String, + val headSha: String, +) : EventPayload + /** * Records that the operator approved access to a specific path OUTSIDE the workspace root * (a `file_read`/`list_dir` target). The intent plane raises PROMPT_USER for any out-of-workspace diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/RepoMapEvents.kt b/core/events/src/main/kotlin/com/correx/core/events/events/RepoMapEvents.kt index d12ac07d..5cec09e3 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/events/RepoMapEvents.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/events/RepoMapEvents.kt @@ -11,6 +11,8 @@ data class RepoMapEntry( val path: String, val score: Double, val symbols: List = emptyList(), + /** Bounded deterministic source-purpose text used for semantic repo retrieval. */ + val descriptor: String = "", ) /** diff --git a/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt b/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt index d2ad3fa1..edf1f26a 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt @@ -89,6 +89,7 @@ import com.correx.core.events.events.WorkflowCompletedEvent import com.correx.core.events.events.WorkflowFailedEvent import com.correx.core.events.events.WorkflowProposedEvent import com.correx.core.events.events.WorkflowStartedEvent +import com.correx.core.events.events.RunBranchPushedEvent import com.correx.core.events.events.TaskCreatedEvent import com.correx.core.events.events.TaskClaimedEvent import com.correx.core.events.events.TaskReleasedEvent @@ -151,6 +152,7 @@ val eventModule = SerializersModule { subclass(OrchestrationResumedEvent::class) subclass(OrchestrationPausedEvent::class) subclass(WorkflowStartedEvent::class) + subclass(RunBranchPushedEvent::class) subclass(WorkflowFailedEvent::class) subclass(WorkflowCompletedEvent::class) subclass(RetryAttemptedEvent::class) diff --git a/core/kernel/AGENTS.md b/core/kernel/AGENTS.md index 7bba11ce..9a3fec0a 100644 --- a/core/kernel/AGENTS.md +++ b/core/kernel/AGENTS.md @@ -20,6 +20,9 @@ CORREX kernel team. This is the integration point for all other `core/` modules. - `SubagentRunner` / `InSessionSubagentRunner` — runs sub-agent invocations within an active session. - `StaticAnalysisRunner` / `ProcessStaticAnalysisRunner` — runs static analysis tools and records results as events. - `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. +- 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. +- Every stage receives a small curated operating-guidance system entry: verify observed state, create required project setup, and resolve necessary scope edges without re-deliberating. - `JournalCompactionService` — triggers journal compaction and emits `JournalCompactedEvent`. - `OrchestratorEngines` / `OrchestratorRepositories` — dependency bundles for wiring. - `WorkspaceContext` / `WorkspaceToolRegistryProvider` — workspace-scoped tool registry provisioning. diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/DefaultSessionOrchestrator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/DefaultSessionOrchestrator.kt index 60be6ecc..b2e0de75 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/DefaultSessionOrchestrator.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/DefaultSessionOrchestrator.kt @@ -69,8 +69,15 @@ internal val GATE_REQUIRED_CAPABILITY: Map = mapOf( "execution" to "file_write", "contract" to "file_write", "static_analysis" to "file_write", + "workspace_precondition" to "file_write", ) +internal const val CURATED_STAGE_OPERATING_GUIDANCE = """## Operating guidance +Verify the current workspace before declaring completion. Read before modifying existing files. +When the authoritative intent plainly requires project setup, creating its manifest, configuration, +or entry file is in scope; do not spend turns re-deciding that settled boundary. Re-observe facts +instead of assuming them, and use the exact gate/tool feedback to make the smallest effective fix.""" + // Deterministic failure category from the gate id (no LLM — keeps routing off the untrusted path). internal fun ticketCategory(gate: String): String = when (gate) { "plan_compile" -> "planning" diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/DefaultSessionOrchestratorRecovery.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/DefaultSessionOrchestratorRecovery.kt index e8c9c866..13c6b10a 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/DefaultSessionOrchestratorRecovery.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/DefaultSessionOrchestratorRecovery.kt @@ -44,8 +44,9 @@ internal suspend fun DefaultSessionOrchestrator.retryStageOrFail( * capability — bounded by the stage's own [RECOVERY_ROUTE_BUDGET]. * * Returns a [StepResult] when it took over the failure (routed, or budget-exhausted terminal), - * or null to fall through to the normal per-gate retry path. Null in every backward-compatible - * case: gate not capability-gated, stage already has the capability, or no recovery stage exists. + * or null to fall through to the normal per-gate retry path. A stage that already holds the + * capability retries in place first; its unchanged-fingerprint retry exhaustion is separately + * routed from the step loop, because capability possession does not prove the owner can apply it. */ internal suspend fun DefaultSessionOrchestrator.maybeRouteToRecovery( @@ -56,7 +57,7 @@ internal suspend fun DefaultSessionOrchestrator.maybeRouteToRecovery( ): StepResult? { val requiredCapability = GATE_REQUIRED_CAPABILITY[failure.gate] ?: return null val stageTools = ctx.graph.stages[stageId]?.allowedTools ?: emptySet() - if (requiredCapability in stageTools) return null // stage has agency — retry in place is valid + if (requiredCapability in stageTools) return null // retry in place before no-progress exhaustion return routeToRecovery(ctx, stageId, failure.gate, requiredCapability, failure.reason, state) } diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/DefaultSessionOrchestratorStep.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/DefaultSessionOrchestratorStep.kt index 21eac88c..2685c655 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/DefaultSessionOrchestratorStep.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/DefaultSessionOrchestratorStep.kt @@ -269,6 +269,20 @@ internal suspend fun DefaultSessionOrchestrator.enterStage( when (gateDecision) { RetryDecision.Retry -> Unit // retry — loop and re-execute RetryDecision.Exhausted -> { + // A stage may hold the nominal capability yet repeatedly fail to apply it + // (scope paralysis / frozen ReAct loop). Exhaustion is only returned for an + // unchanged fingerprint, so hand that no-progress dead-end to the + // intent-holder rather than declaring the workflow failed in place. + GATE_REQUIRED_CAPABILITY[result.gate]?.let { capability -> + routeToRecovery( + ctx, + stageId, + result.gate, + capability, + result.reason, + refreshedState, + )?.let { return it } + } decideGateExhaustion( ctx, stageId, result.gate, result.reason, refreshedState, )?.let { return it } diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorExecution.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorExecution.kt index c18ca8e6..0bdf3f85 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorExecution.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorExecution.kt @@ -81,6 +81,17 @@ internal suspend fun SessionOrchestrator.executeStage( ), ) } ?: emptyList() + val operatingGuidance = listOf( + ContextEntry( + id = ContextEntryId(UUID.randomUUID().toString()), + layer = ContextLayer.L0, + content = CURATED_STAGE_OPERATING_GUIDANCE, + sourceType = "operatingGuidance", + sourceId = "curated-v1", + tokenEstimate = estimateTokens(CURATED_STAGE_OPERATING_GUIDANCE), + role = EntryRole.SYSTEM, + ), + ) // A stage re-entered to repair a gate failure has its own (often generative/"scaffold") prompt // SUPPRESSED: that mandate is what drove it to overwrite real files with stubs on re-entry. The // recovery-ticket entry (buildRecoveryTicketEntry) is the sole mandate here — it already carries @@ -239,7 +250,7 @@ internal suspend fun SessionOrchestrator.executeStage( ?.let { buildRemainingDeltaEntry(contractFailureItems(it)) } ?.let { listOf(it) } ?: emptyList() var accumulatedEntries = stampBuckets( - systemPrompt + intentEntries + profileEntries + projectProfileEntries + agentInstructionsEntries + + systemPrompt + operatingGuidance + intentEntries + profileEntries + projectProfileEntries + agentInstructionsEntries + journalEntries + repoMapEntries + claimedTaskEntries + needsEntries + schemaEntries + vocabularyEntries + promptEntries + steeringEntries + clarificationEntries + retryFeedbackEntries + recoveryTicketEntries + remainingDeltaEntries, @@ -390,6 +401,9 @@ internal suspend fun SessionOrchestrator.executeStage( inferenceResult.response.toolCalls, stageConfig, effectives, approvalModeFor(session.state.boundProfile?.approvalMode), inferenceResult.response.reasoning) + repeatedBuildCriticalReferenceBlock(sessionId, stageId)?.let { reason -> + return StageExecutionResult.Failure(reason, retryable = true, gate = "workspace_precondition") + } val fatalEntry = toolEntries.firstOrNull { it.content.startsWith("FATAL:") } if (fatalEntry != null) { emitProcessResultEvents(sessionId, stageId, stageConfig) diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorGates.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorGates.kt index 7225214e..fb0e2a36 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorGates.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorGates.kt @@ -258,6 +258,7 @@ internal suspend fun SessionOrchestrator.evaluateStageContract( layer = a.layer.name, evaluator = a.evaluator.name, passed = v.passed, + skipped = v.skipped, evidence = v.evidence.takeLast(CONTRACT_EVIDENCE_CAP), ) } diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorPreconditions.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorPreconditions.kt new file mode 100644 index 00000000..97085d22 --- /dev/null +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorPreconditions.kt @@ -0,0 +1,44 @@ +package com.correx.core.kernel.orchestration + +import com.correx.core.events.events.ToolCallAssessedEvent +import com.correx.core.events.events.ToolInvocationRequestedEvent +import com.correx.core.events.risk.RiskAction +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId + +/** + * Repeated attempts to read a missing build prerequisite are no longer mere ReAct noise. Once the + * same build-critical path has been durably blocked three times in one stage, return a retryable + * gate failure so the normal recovery ladder can bootstrap it (or request a different owner). + */ +internal fun SessionOrchestrator.repeatedBuildCriticalReferenceBlock( + sessionId: SessionId, + stageId: StageId, +): String? { + val events = eventStore.read(sessionId) + val requests = events + .mapNotNull { it.payload as? ToolInvocationRequestedEvent } + .filter { it.stageId == stageId } + .associateBy { it.invocationId } + val paths = events + .mapNotNull { it.payload as? ToolCallAssessedEvent } + .filter { it.stageId == stageId && it.disposition == RiskAction.BLOCK } + .filter { event -> event.issues.any { it.code == REFERENCE_EXISTS_CODE } } + .mapNotNull { event -> requests[event.invocationId]?.request?.parameters?.get("path") as? String } + .filter(::isBuildCriticalPath) + val repeated = paths.groupingBy { it }.eachCount().entries + .firstOrNull { it.value >= REFERENCE_EXISTS_REPEAT_LIMIT } + ?: return null + return "stage ${stageId.value} repeatedly referenced missing build prerequisite '${repeated.key}' " + + "(${repeated.value} blocked attempts). Create or repair the project setup before continuing." +} + +private fun isBuildCriticalPath(path: String): Boolean { + val name = path.substringAfterLast('/').lowercase() + return name in BUILD_PREREQUISITE_FILENAMES +} + +private const val REFERENCE_EXISTS_REPEAT_LIMIT = 3 +private val BUILD_PREREQUISITE_FILENAMES = setOf( + "package.json", "tsconfig.json", "vite.config.ts", "build.gradle", "build.gradle.kts", "pom.xml", +) diff --git a/core/transitions/AGENTS.md b/core/transitions/AGENTS.md index 193db0a0..a5b07e20 100644 --- a/core/transitions/AGENTS.md +++ b/core/transitions/AGENTS.md @@ -27,6 +27,7 @@ CORREX kernel team. - `DefaultTransitionResolver` is stateless per call — it reads `EvaluationContext`, not persisted state. - Stage events are recorded by `DefaultStageExecutionEventMapper`; the mapper must emit events for every outcome (success, failure, skip). - `WorkflowGraph` is built from config/TOML at session start; it is immutable during a session. +- `StageConfig.autoBuildGate` is a compiler-set request for a runtime build gate; it is used on write-declaring freestyle stages only when no explicit build expectation exists. - Cycle detection (`CycleExtractor`) runs during graph validation (`core:validation`), not at runtime. ## Verification diff --git a/core/transitions/src/main/kotlin/com/correx/core/transitions/graph/StageConfig.kt b/core/transitions/src/main/kotlin/com/correx/core/transitions/graph/StageConfig.kt index 49c574fb..1ff834cb 100644 --- a/core/transitions/src/main/kotlin/com/correx/core/transitions/graph/StageConfig.kt +++ b/core/transitions/src/main/kotlin/com/correx/core/transitions/graph/StageConfig.kt @@ -52,7 +52,7 @@ data class StageConfig( // correctness FAIL can block retryably until the review-block budget is spent. Default off. val semanticReview: Boolean = false, // Deterministic build-gate floor (staged-verification §Gate 4). Set by the compiler on the plan's - // terminal stage when no stage declares an explicit [buildExpectation]. The LLM planner emits every + // write-declaring plan stage when no stage declares an explicit [buildExpectation]. The LLM planner emits every // file-writing stage as the generic `file_written` kind (never a typed code kind) and rarely sets // build_expectation, so a plan that scaffolds real code would otherwise never hit an execution gate. // When this is set and NONE is declared, runExecutionGate promotes to a PROJECT build ONLY IF the diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 617b16dc..aa8bf5bf 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -12,7 +12,7 @@ Maintained alongside the features they describe. Any agent shipping a significan - `architecture/` — stable, high-level architectural docs (context layers, event model, replay model, security boundaries). - `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. `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. - `schemas/` — canonical JSON schemas for structured outputs (analysis, brief_echo, design, execution_plan, impl_plan). Shared across the router and validator. - `epics/`, `modules/`, `diagrams/`, `design/`, `reviews/`, `visual/` — supporting reference material. diff --git a/docs/qa/QA-stage-prompt-audition.md b/docs/qa/QA-stage-prompt-audition.md new file mode 100644 index 00000000..004f9a11 --- /dev/null +++ b/docs/qa/QA-stage-prompt-audition.md @@ -0,0 +1,32 @@ +# QA Plan: Stage prompt audition + +**Status:** DRAFT +**Run date / operator:** Not run — requires a fixed local model and seed. +**BACKLOG item:** Vikunja #169 + +## Preconditions + +- [ ] Record the model id, model checksum, `context_size`, and sampling config. +- [ ] Use one fixed workspace fixture and deterministic seed per variant. +- [ ] Run `scripts/prompt-audit.sh` and retain its Markdown output with the run artifacts. + +## Acceptance gate + +> A curated prompt variant is adopted only if it improves gate pass rate or wasted tool rounds without increasing context truncation on the same model and task fixture. + +## Checks + +| # | Action | Expected observable evidence | Result | +|---|---|---|---| +| 1 | Run baseline, trimmed, curated-rules, and scope-reworded variants. | Session event logs identify every stage inference and terminal workflow outcome. | Not run | +| 2 | Compare gate pass rate, rejected/wasted tool rounds, retries, token budget use, and truncation. | Per-variant result table derived from events; same fixture and seed. | Not run | +| 3 | Select the smallest improving variant. | A committed finding names the selected variant and evidence; otherwise no prompt expansion lands. | Not run | + +## Current static audit finding + +The runtime currently has two stage-facing sources: workflow prompt files and the compact `CURATED_STAGE_OPERATING_GUIDANCE` L0 system entry. Retry feedback remains event-derived and only the latest failure is injected. The static inventory is reproducible with `scripts/prompt-audit.sh`; no performance claim is made until the controlled run above is recorded. + +## Out of scope + +- A live benchmark result; this repository has no fixed model artifact or benchmark fixture checked in. +- Replacing deterministic gates with prompt text. diff --git a/docs/specs/2026-07-15-capability-accretion.md b/docs/specs/2026-07-15-capability-accretion.md new file mode 100644 index 00000000..c744d26a --- /dev/null +++ b/docs/specs/2026-07-15-capability-accretion.md @@ -0,0 +1,12 @@ +# Capability accretion + +Make run N+1 better through deterministic, replay-safe facts mined from validated prior runs—not model training or mutable hidden memory. + +Promote a validated failure-to-fix pair by a reusable class key: gate, evaluator/toolchain kind, and normalized failure fingerprint. Store a reference to the validated fix artifact/diff and the evidence that it passed. + +1. Extend the concept rails to retain `classKey`, `fixRef`, confidence, and contradiction state. +2. Deliver high-confidence facts deterministically at plan compilation (contract/gate tightening) or as a bounded stage instruction. L3 remains an instance-recall fallback. +3. Mine positive patterns separately: successful plan shapes, resolved commands, and useful retrievals. +4. Revoke or decay facts when later recorded evidence contradicts them. + +Every promotion and delivery is event-derived and recorded; replay never re-mines live state. diff --git a/docs/specs/2026-07-15-closed-loop-workspace.md b/docs/specs/2026-07-15-closed-loop-workspace.md new file mode 100644 index 00000000..b7e0f3ef --- /dev/null +++ b/docs/specs/2026-07-15-closed-loop-workspace.md @@ -0,0 +1,18 @@ +# Closed-loop workspace + +## Invariant + +After every code-producing stage, the workspace has a recorded verification observation. A passing observation is the only state the next implementation stage may treat as known-good; replay reads the event rather than re-running a command. + +## Seams + +1. Before locking a plan, ground declared writes, referenced paths, and build prerequisites against the workspace/index. Unknown or absent prerequisites return the plan to the architect. +2. For each write-declaring stage, run the appropriate static/build command and record its result. Failures retry or route through recovery before downstream work continues. +3. Repeated hard precondition observations (for example `REFERENCE_EXISTS`) become a stage failure or bootstrap/clarification decision rather than independent rejected tool calls. + +## Delivery order + +- Per-write-stage build gates and truthful deferred contract verdicts (#162). +- Consume repeated build-critical `REFERENCE_EXISTS` blocks (#163): three blocks for one manifest/config path now create a recovery-eligible precondition failure. +- Ground plan prerequisites before plan lock; then use the verified observation as the next-stage planning baseline. +- Preserve recovery escalation for failures that still cannot reach a verified increment (#165). diff --git a/docs/specs/2026-07-15-stage-prompt-audit.md b/docs/specs/2026-07-15-stage-prompt-audit.md new file mode 100644 index 00000000..f42fb10a --- /dev/null +++ b/docs/specs/2026-07-15-stage-prompt-audit.md @@ -0,0 +1,15 @@ +# Stage prompt audit and audition + +## Audit + +Inventory each system-role/context block, its origin, token estimate, and outcome evidence. Remove redundant or stale text before adding rules. Inspect retry feedback separately: only the latest failure belongs in the prompt. + +## Candidate additions + +- Project build manifests, configs, and entry files are normal in-scope setup when required by the authoritative intent. +- Resolve a necessary scope edge from the authoritative intent; do not spend turns re-deciding it. +- Read/observe before writing and verify before declaring completion. + +## Audition harness + +Run a fixed task and seed through baseline, trimmed, curated-rules, and reworded-scope variants. Record completion, gate pass rate, rejected/wasted tool rounds, retries, token budget use, and context truncation. Choose the smallest variant that improves the 12B results; structural gates remain preferred where a deterministic mechanism exists. diff --git a/examples/AGENTS.md b/examples/AGENTS.md index 77313498..2820fcb4 100644 --- a/examples/AGENTS.md +++ b/examples/AGENTS.md @@ -21,6 +21,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. - 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. - Do not add configs/plugins/stages stubs speculatively — populate when there is real content. ## Verification diff --git a/examples/workflows/prompts/architect_freestyle.md b/examples/workflows/prompts/architect_freestyle.md index b74aa58a..713bce68 100644 --- a/examples/workflows/prompts/architect_freestyle.md +++ b/examples/workflows/prompts/architect_freestyle.md @@ -81,6 +81,10 @@ Emit a JSON object that validates against the `execution_plan` schema: decompose tasks here — task creation is out of scope for the plan. - Keep stages small and single-responsibility. Prefer more stages over large monolithic prompts. +- Write stage prompts as constraints and verification goals, not a pre-scripted list of exact + resulting files or their full contents. State the responsibility, boundaries, existing APIs or + references to honor, acceptance checks, and what must not change; let the implementer inspect the + actual workspace and choose the smallest fitting file edits. - **Declare `touches` to fence a stage to its area.** When a stage's job belongs to one part of the tree (a frontend/client stage → `["frontend/**"]`; a stage that edits only the API layer → `["apps/server/**"]`), list those globs in `touches`. Every concrete path the stage `writes` diff --git a/infrastructure/inference/AGENTS.md b/infrastructure/inference/AGENTS.md index c1317836..374b1c9c 100644 --- a/infrastructure/inference/AGENTS.md +++ b/infrastructure/inference/AGENTS.md @@ -15,6 +15,7 @@ Adapter for LLM inference backends. Implements `core:inference` interfaces. No d - All LLM responses are proposals — they must be validated by the core before affecting state (invariant #7). Adapters return raw responses; they do not validate. - Network calls to inference backends are environment observations; results must be recorded as events by callers if replay must reproduce them (invariant #9). - Model lifecycle (spawn/own the llama-server process) lives in `llama_cpp/`; the autonomous scheduler is intentionally unbuilt (see root CLAUDE.md). +- Model descriptors default to a 24,576-token context window, matching the implementation-stage budget; operators may lower `[[models]].context_size` for constrained hardware. - `openai_compat/` is fully remote (no local process/GPU). It speaks `POST {baseUrl}/chat/completions` with `Authorization: Bearer`; `baseUrl` must include the version segment (e.g. `/v1`). It has no `/tokenize`, so it uses a heuristic tokenizer, and no GBNF — JSON artifacts rely on the core's validate-after-retry. Server dispatch keys `[[providers]] type = "nim" | "openai"`; the key comes from `api_key` or `api_key_env`. ## Work Guidance diff --git a/infrastructure/inference/commons/src/main/kotlin/com/correx/infrastructure/inference/commons/ModelDescriptor.kt b/infrastructure/inference/commons/src/main/kotlin/com/correx/infrastructure/inference/commons/ModelDescriptor.kt index ddea85a2..f8ddf3b9 100644 --- a/infrastructure/inference/commons/src/main/kotlin/com/correx/infrastructure/inference/commons/ModelDescriptor.kt +++ b/infrastructure/inference/commons/src/main/kotlin/com/correx/infrastructure/inference/commons/ModelDescriptor.kt @@ -10,6 +10,6 @@ data class ModelDescriptor( val modelPath: String, val residencyMode: ResidencyMode, val idleTimeoutMs: Long = 60_000L, - val contextSize: Int = 8192, + val contextSize: Int = 24_576, val capabilities: Set = emptySet(), ) diff --git a/infrastructure/tools/AGENTS.md b/infrastructure/tools/AGENTS.md index 6d51f4c3..7ca4cb53 100644 --- a/infrastructure/tools/AGENTS.md +++ b/infrastructure/tools/AGENTS.md @@ -17,6 +17,7 @@ Adapter for `core:tools`. Depends on `core:tools`, `core:events`, `core:approval - `ToolConfig` is the only configuration surface; pass via `InfrastructureModule.createToolExecutor()`. - `buildTools()` extension on `ToolConfig` assembles the full tool list; add new tools there, not in the registry directly. - Filesystem mutation is split by intent: `file_write` only writes (`{path, content}`), `file_edit` edits, and `file_delete` only deletes (`{path}`) — deletion is a separately-named capability so a model can never delete by getting a write-mode parameter wrong. `file_delete` shares `file_write`'s path jail and `fileWrite.enabled` toggle and carries `ToolCapability.FILE_WRITE`. +- `list_dir` is shallow by default, but collapses a non-symlink single-child directory chain (bounded depth) to the first branch point and explains that expansion in its output; recursive listings retain normal tree traversal. ## Work Guidance diff --git a/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/ListDirTool.kt b/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/ListDirTool.kt index 03548afa..2589fcf8 100644 --- a/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/ListDirTool.kt +++ b/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/ListDirTool.kt @@ -127,6 +127,33 @@ class ListDirTool( } private fun walk(root: Path, recursive: Boolean, request: ToolRequest): ToolResult { + var listedRoot = root + var listing = collect(listedRoot, recursive) + val descended = ArrayList() + // Do not follow symlinks: a symlink chain can point back to an ancestor outside the walk's + // normal cycle protections. A real directory chain is capped as a second guard against a + // pathological generated tree. + while (!recursive && descended.size < MAX_AUTO_DESCENT_DEPTH) { + val only = listing.entries.singleOrNull() ?: break + val child = listedRoot.resolve(only.removeSuffix("/")) + if (!only.endsWith('/') || Files.isSymbolicLink(child) || !Files.isDirectory(child)) break + descended += only.removeSuffix("/") + listedRoot = child + listing = collect(listedRoot, recursive = false) + } + return ToolResult.Success( + request.invocationId, + output = render(listedRoot, listing.entries, listing.truncated, listing.ignoredCount, descended), + ) + } + + private data class Listing( + val entries: List, + val truncated: Boolean, + val ignoredCount: Int, + ) + + private fun collect(root: Path, recursive: Boolean): Listing { val matcher = GitignoreMatcher(workingDir ?: root, root) // The caller explicitly targeted `root` — if a rule already covers it (e.g. an entire // subtree gitignored as scaffold output), don't let that same rule keep pruning everything @@ -166,7 +193,7 @@ class ListDirTool( override fun visitFileFailed(file: Path, exc: java.io.IOException) = FileVisitResult.CONTINUE }) out.sort() - return ToolResult.Success(request.invocationId, output = render(root, out, truncated, ignoredCount)) + return Listing(out, truncated, ignoredCount) } /** @@ -176,8 +203,17 @@ class ListDirTool( * gitignore-pruning are surfaced here too, so a shorter-than-expected listing isn't mistaken for * the real tree. */ - private fun render(root: Path, entries: List, truncated: Boolean, ignoredCount: Int): String { + private fun render( + root: Path, + entries: List, + truncated: Boolean, + ignoredCount: Int, + descended: List, + ): String { val notes = buildList { + if (descended.isNotEmpty()) { + add("auto-descended ${descended.joinToString(" → ")} (single child at each level); listing shown is $root") + } if (truncated) add("truncated at $MAX_ENTRIES entries; narrow the path or list a sub-directory") if (ignoredCount > 0) { add("$ignoredCount ${if (ignoredCount == 1) "entry" else "entries"} hidden by .gitignore") @@ -240,6 +276,7 @@ class ListDirTool( private companion object { const val MAX_ENTRIES = 400 + const val MAX_AUTO_DESCENT_DEPTH = 32 const val MAX_SIMILAR_SUGGESTIONS = 3 const val SIMILAR_NAME_MIN_COMMON_PREFIX = 2 const val SIMILAR_NAME_MAX_LEVENSHTEIN = 3 diff --git a/infrastructure/tools/filesystem/src/test/kotlin/com/correx/infrastructure/tools/filesystem/ListDirToolTest.kt b/infrastructure/tools/filesystem/src/test/kotlin/com/correx/infrastructure/tools/filesystem/ListDirToolTest.kt index 6b0cda12..8ea1cdc9 100644 --- a/infrastructure/tools/filesystem/src/test/kotlin/com/correx/infrastructure/tools/filesystem/ListDirToolTest.kt +++ b/infrastructure/tools/filesystem/src/test/kotlin/com/correx/infrastructure/tools/filesystem/ListDirToolTest.kt @@ -49,9 +49,10 @@ class ListDirToolTest { fun `default is shallow — a bare list_dir does not descend`(): Unit = runBlocking { // Regression: a recursive default buried top-level answers ("does frontend/ exist?") under a // 400-entry alphabetical flood and drove models to re-issue the same list_dir 2-3x. A bare - // call (no recursive param) must now list only immediate children. + // call (no recursive param) must not descend a directory that already has a meaningful choice. val root = Files.createTempDirectory("listdir") Files.createDirectories(root.resolve("src/deep")).also { Files.writeString(it.resolve("f.ts"), "f") } + Files.writeString(root.resolve("README.md"), "root file") val tool = ListDirTool(allowedPaths = setOf(root), workingDir = root) val out = (tool.execute(request()) as ToolResult.Success).output assertTrue(out.contains("src/"), out) @@ -75,9 +76,26 @@ class ListDirToolTest { fun `non-recursive lists only immediate children`(): Unit = runBlocking { val root = Files.createTempDirectory("listdir") Files.createDirectories(root.resolve("src/deep")).also { Files.writeString(it.resolve("f.ts"), "f") } + Files.writeString(root.resolve("README.md"), "root file") val tool = ListDirTool(allowedPaths = setOf(root), workingDir = root) val out = (tool.execute(request(recursive = false)) as ToolResult.Success).output assertTrue(out.contains("src/"), out) assertFalse(out.contains("deep"), out) } + + @Test + fun `non-recursive listing auto-descends a single-child directory chain`(): Unit = runBlocking { + val root = Files.createTempDirectory("listdir") + Files.createDirectories(root.resolve("core/kernel/src/main")) + Files.writeString(root.resolve("core/kernel/src/main/App.kt"), "class App") + Files.createDirectories(root.resolve("core/kernel/src/main/resources")) + + val tool = ListDirTool(allowedPaths = setOf(root), workingDir = root) + val out = (tool.execute(request(recursive = false)) as ToolResult.Success).output + + assertTrue(out.contains("${root.resolve("core/kernel/src/main")}"), out) + assertTrue(out.contains("App.kt"), out) + assertTrue(out.contains("resources/"), out) + assertTrue(out.contains("auto-descended core → kernel → src → main"), out) + } } diff --git a/infrastructure/workflow/AGENTS.md b/infrastructure/workflow/AGENTS.md index cffa14cb..ff20fb66 100644 --- a/infrastructure/workflow/AGENTS.md +++ b/infrastructure/workflow/AGENTS.md @@ -15,6 +15,7 @@ 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. - `PlanLinter` enforces structural rules (e.g. unreachable stages are rejected); linting failures throw `WorkflowValidationException`. - `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. ## Work Guidance diff --git a/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompiler.kt b/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompiler.kt index 5e7165ac..0d7391ab 100644 --- a/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompiler.kt +++ b/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompiler.kt @@ -55,7 +55,7 @@ private const val RECOVERY_PROMPT = // round, evicting the file_read results the model just gathered, so it re-reads forever and never // accumulates enough to write. Match the static execution baseline. (gemma ctx is 32768, so this // leaves ample headroom for completion.) -private const val DEFAULT_STAGE_TOKEN_BUDGET = 16384 +private const val DEFAULT_STAGE_TOKEN_BUDGET = 24576 // A compiled freestyle stage must also lift its inference completion cap off StageConfig's 2048 // default, or the model is truncated (finishReason=length) mid-artifact — and a degenerating local @@ -114,8 +114,11 @@ class ExecutionPlanCompiler( // ACTUALLY builds is decided at run time (SessionOrchestrator.runExecutionGate) from the real // 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. - val autoGateStageId: String? = - if (declaredExpectations.values.all { it == BuildExpectation.NONE }) terminalStageId(plan) else null + val autoGateStages: Set = if (declaredExpectations.values.all { it == BuildExpectation.NONE }) { + plan.stages.filter { it.writes.any { path -> path.isNotBlank() } }.map { it.id }.toSet() + } else { + emptySet() + } val stageMap = plan.stages.associate { s -> // `produces` is the unique slot name referenced by needs/edges; `kind` selects the @@ -149,7 +152,7 @@ class ExecutionPlanCompiler( expectedFiles = s.writes.map { it.trim() }.filter { it.isNotEmpty() && !isGlob(it) }, touches = s.touches.map { it.trim() }.filter { it.isNotEmpty() }, buildExpectation = declaredExpectations.getValue(s.id), - autoBuildGate = s.id == autoGateStageId, + autoBuildGate = s.id in autoGateStages, semanticReview = s.semanticReview, tokenBudget = DEFAULT_STAGE_TOKEN_BUDGET, generationConfig = defaultStageGeneration, diff --git a/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompilerTest.kt b/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompilerTest.kt index 2d551ad9..7cb50b10 100644 --- a/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompilerTest.kt +++ b/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompilerTest.kt @@ -470,9 +470,9 @@ class ExecutionPlanCompilerTest { "goal": "scaffold a react app", "stages": [ { "id": "entry", "prompt": "write main.tsx", "produces": "app_entry", "kind": "react_entry", - "needs": [], "tools": ["file_write"] }, + "needs": [], "tools": ["file_write"], "writes": ["src/main.tsx"] }, { "id": "views", "prompt": "write components", "produces": "app_views", "kind": "react_component", - "needs": ["app_entry"], "tools": ["file_write"] } + "needs": ["app_entry"], "tools": ["file_write"], "writes": ["src/App.tsx"] } ], "edges": [ { "from": "entry", "to": "views", "condition": { "type": "always_true" } }, @@ -482,12 +482,12 @@ class ExecutionPlanCompilerTest { """.trimIndent() @Test - fun `terminal stage of an ungated plan is flagged for an auto build gate`() { + fun `every write-declaring stage of an ungated plan is flagged for an auto build gate`() { val graph = codeCompiler.compile(codePlanNoGate, "wf") val views = graph.stages[StageId("views")]!! assertTrue( views.autoBuildGate, - "terminal stage of an ungated plan must be flagged for an auto build gate", + "a write-declaring stage of an ungated plan must be flagged for an auto build gate", ) assertEquals( com.correx.core.transitions.graph.BuildExpectation.NONE, @@ -496,8 +496,8 @@ class ExecutionPlanCompilerTest { "code module was actually written", ) assertTrue( - !graph.stages[StageId("entry")]!!.autoBuildGate, - "intermediate stages are not flagged — the gate runs when the project is whole, not per-stage", + graph.stages[StageId("entry")]!!.autoBuildGate, + "implementation stages must not defer compiler coverage to a terminal verifier", ) } @@ -531,15 +531,15 @@ class ExecutionPlanCompilerTest { } @Test - fun `exactly the terminal stage of an ungated plan is auto-flagged`() { - // The compiler flags the terminal stage regardless of the produced kinds — code-ness is a - // run-time decision (SessionOrchestrator reads the FileWritten manifest), so a non-code plan - // is flagged too but the runtime build gate then skips it. + fun `plan without declared writes has no auto build gate`() { + // A plan with no write declarations cannot promise compiler coverage. Runtime still + // inspects actual files for an opted-in gate, but the compiler must not pretend this + // documentation-shaped plan has implementation-stage verification. val graph = compiler.compile(validPlan, "wf") assertEquals( - 1, + 0, graph.stages.values.count { it.autoBuildGate }, - "exactly the single terminal stage of an ungated plan is flagged for the auto build gate", + "only write-declaring stages receive an auto build gate", ) } } diff --git a/scripts/prompt-audit.sh b/scripts/prompt-audit.sh new file mode 100755 index 00000000..250ab8d7 --- /dev/null +++ b/scripts/prompt-audit.sh @@ -0,0 +1,27 @@ +#!/bin/sh +# Inventory the stage-facing prompt surfaces before changing prompt policy. This is intentionally +# static: it is safe to run without a model, server, event store, or workspace mutation. +set -eu + +root=${1:-.} +cd "$root" + +printf '%s\n\n' '# Stage prompt inventory' +printf '%s\n' '| Surface | Source | Lines | Approx. tokens |' +printf '%s\n' '|---|---|---:|---:|' + +find examples/workflows/prompts -type f -name '*.md' -print 2>/dev/null | sort | while IFS= read -r file; do + lines=$(wc -l < "$file" | tr -d ' ') + chars=$(wc -c < "$file" | tr -d ' ') + printf '| workflow prompt | `%s` | %s | %s |\n' "$file" "$lines" "$((chars / 4))" +done + +printf '| curated system guidance | `%s` | %s | %s |\n' \ + 'core/kernel/.../DefaultSessionOrchestrator.kt' \ + "$(awk '/CURATED_STAGE_OPERATING_GUIDANCE =/{on=1} on{n++} /"""$/{if(on){print n; exit}}' core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/DefaultSessionOrchestrator.kt)" \ + "$(awk '/CURATED_STAGE_OPERATING_GUIDANCE =/{on=1} on{c+=length($0)+1} /"""$/{if(on){print int(c/4); exit}}' core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/DefaultSessionOrchestrator.kt)" + +printf '\n## Candidates for review\n\n' +rg -n -i '(^|[^[:alnum:]_])(must|always|never|required|exactly)([^[:alnum:]_]|$)' \ + examples/workflows/prompts core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/DefaultSessionOrchestrator.kt \ + 2>/dev/null || true diff --git a/testing/integration/src/test/kotlin/RecoveryRoutingTest.kt b/testing/integration/src/test/kotlin/RecoveryRoutingTest.kt index edfebf96..8123b8ba 100644 --- a/testing/integration/src/test/kotlin/RecoveryRoutingTest.kt +++ b/testing/integration/src/test/kotlin/RecoveryRoutingTest.kt @@ -398,6 +398,27 @@ class RecoveryRoutingTest { start = StageId("impl"), ) + // impl owns file_write but repeatedly produces the same static-analysis failure. Capability + // possession alone is not progress: after the per-gate retry budget is spent, this must still + // reach the recovery/intent-holder stage instead of terminating impl in place. + private fun capableButStuckGraph(): WorkflowGraph = WorkflowGraph( + id = "capable-but-stuck-test", + stages = mapOf( + StageId("impl") to StageConfig( + allowedTools = setOf("file_write"), + staticAnalysis = listOf("typecheck"), + ), + StageId("recovery") to StageConfig( + allowedTools = setOf("shell", "file_write"), + metadata = mapOf("role" to "recovery"), + ), + ), + transitions = setOf( + TransitionEdge(TransitionId("t-impl"), StageId("impl"), StageId("done"), condition = { true }), + ), + start = StageId("impl"), + ) + /** Builds an orchestrator whose only writing stage is `impl` (via file_write) and whose `verify` * static gate fails with [staticOutput] — so route-to-owner can resolve `impl` as the file author. */ private fun buildMultiToolOrchestrator( @@ -520,6 +541,33 @@ class RecoveryRoutingTest { assertTrue(tickets.take(firstEscalatedIdx).none { it.escalated }, "owner routes come before escalation") } + @Test + fun `capable stage with unchanged failure exhausts to the recovery arbiter`(): Unit = runBlocking { + val (orchestrator, eventStore) = buildMultiToolOrchestrator( + staticOutput = "App.tsx: TS2322 type mismatch", + ) + val sessionId = SessionId("capable-but-stuck") + val config = OrchestrationConfig( + retryPolicy = RetryPolicy( + maxAttempts = 10, + backoffMs = 0, + perGateMaxAttempts = mapOf("static_analysis" to 1), + ), + ) + + val result = orchestrator.run(sessionId, capableButStuckGraph(), config) + + assertTrue(result is WorkflowResult.Failed, "the bounded repair ladder must still terminate") + val tickets = eventStore.read(sessionId).mapNotNull { it.payload as? FailureTicketOpenedEvent } + assertEquals(RECOVERY_ROUTE_BUDGET_TEST, tickets.size) + tickets.forEach { + assertEquals("impl", it.stageId.value) + assertEquals("recovery", it.routeTo.value) + assertEquals("static_analysis", it.gate) + assertTrue(it.escalated, "a self-owned failure must escalate directly to the intent-holder") + } + } + // impl carries a generative "scaffold" mandate as its own prompt. On the FORWARD visit it applies; // when the ticket routes impl back to repair its own file, that mandate must be suppressed (else it // re-scaffolds and stubs the real file). Same topology as ownerGraph otherwise.