From 9ac32c9e93fb98ef07674affc3995e0e4a88b1b3 Mon Sep 17 00:00:00 2001 From: kami Date: Sun, 19 Jul 2026 23:24:28 +0400 Subject: [PATCH] fix(plan-grounding): credit a file-writing stage's scope as will-exist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Session d038468a: architect's plans compiled clean (3x) but plan grounding then rejected them. The scaffold_frontend_project stage runs `npm create vite` (allowed_tools [file_write, shell], touches [frontend/]) to create frontend/ + package.json at run time. PlanGrounder only credited declared writes/ expectedFiles, so the scaffolder's generated files were invisible — it false-rejected all three: frontend/ "doesn't exist", verify stage has "no manifest". That rejects exactly the plan the architect prompt asks for ("use the real scaffolder, don't hand-write package.json"). Credit a file_write-capable stage's declared `touches` scope as populated by run time: it satisfies the build-manifest prerequisite and any scope (its own or a later stage's) that overlaps it. Keyed on file_write (create-intent), NOT shell, so a read-only shell stage — log inspection, test runs, grep — creates nothing and does not wrongly credit its scope. Runtime precondition handling (#167/#170) remains the backstop for a build whose prerequisite genuinely never appears. Tests: scaffolder case (mirrors the session) grounds; read-only shell stage does NOT; existing "missing prerequisite" reject still holds. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015Ly2mMnt9TCZbvhcC1JfuV --- .../infrastructure/workflow/PlanGrounder.kt | 42 ++++++++++++++++--- .../workflow/PlanGrounderTest.kt | 28 +++++++++++++ 2 files changed, 65 insertions(+), 5 deletions(-) diff --git a/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/PlanGrounder.kt b/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/PlanGrounder.kt index d635d592..c58b39ff 100644 --- a/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/PlanGrounder.kt +++ b/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/PlanGrounder.kt @@ -47,9 +47,23 @@ object PlanGrounder { val manifestProducedByPlan = graph.stages.values .flatMap { it.writeManifest + it.expectedFiles } .any(::isBuildManifest) - val prerequisiteAvailable = manifestInWorkspace || manifestProducedByPlan - // Files any stage in the plan will create — the "will exist by run time" set for scope grounding. + // A stage that can WRITE files (`file_write`) POPULATES its declared `touches` scope at run time + // — including files a scaffolder (`npm create vite …`) generates but the plan never declares in + // `writes`/`expectedFiles` (the architect prompt tells the model to use the real scaffolder, not + // hand-write package.json). Keyed on file_write, NOT shell: a scaffold stage carries file_write + // for the files it authors, whereas a read-only shell stage (log inspection, test runs, grep) + // creates nothing and must not have its scope credited. Without this, grounding rejects every + // plan that follows the scaffolder guidance (the d038468a "frontend/ doesn't exist / no manifest" + // false reject). Runtime precondition handling (#167/#170) stays the backstop for a build whose + // prerequisite genuinely never materialises. + val writeScopes = graph.stages.values + .filter { "file_write" in it.effectiveAllowedTools } + .flatMap { it.touches } + .mapNotNull(::scopePrefix) + val prerequisiteAvailable = manifestInWorkspace || manifestProducedByPlan || writeScopes.isNotEmpty() + + // Files/dirs any stage in the plan will create — the "will exist by run time" set for scope grounding. val createdPaths = graph.stages.values.flatMap { it.expectedFiles + it.writeManifest } val findings = graph.stages.entries.flatMap { (id, cfg) -> @@ -69,7 +83,7 @@ object PlanGrounder { // "scoped to EXISTING frontend/ that isn't there" failure. Caught at stage 0, not stage 11. if (scanned) { cfg.touches - .filter { glob -> !scopeSatisfied(glob, repoMapPaths, createdPaths) } + .filter { glob -> !scopeSatisfied(glob, repoMapPaths, createdPaths, writeScopes) } .forEach { glob -> add( "stage ${id.value}: scoped to '$glob' but nothing under it exists in the repo map " + @@ -86,8 +100,21 @@ object PlanGrounder { ) } - /** True if any existing repo-map path or any plan-created path falls under the [glob] scope. */ - private fun scopeSatisfied(glob: String, repoMapPaths: Set, createdPaths: List): Boolean { + /** + * True if the [glob] scope will be populated by run time: an existing repo-map path or a + * plan-created path falls under it, OR it overlaps a scaffold stage's scope ([writeScopes] — + * either direction, since a scaffolder under `frontend/src` also makes a `frontend/` scope non-empty + * and a scaffolder of `frontend/` covers `frontend/src`). + */ + private fun scopeSatisfied( + glob: String, + repoMapPaths: Set, + createdPaths: List, + writeScopes: List, + ): Boolean { + scopePrefix(glob)?.let { p -> + if (writeScopes.any { it == p || it.startsWith("$p/") || p.startsWith("$it/") }) return true + } val matcher: PathMatcher = runCatching { FileSystems.getDefault().getPathMatcher("glob:${glob.trimStart('/')}") }.getOrElse { return true } // ponytail: an unparseable glob is not a grounding failure — don't block on it. @@ -95,6 +122,11 @@ object PlanGrounder { return repoMapPaths.any(::matches) || createdPaths.any(::matches) } + // A scope glob reduced to its literal directory prefix (a "frontend" wildcard glob → "frontend"), + // or null when the glob has no literal prefix. + private fun scopePrefix(glob: String): String? = + glob.trimStart('/').substringBefore('*').trimEnd('/').ifEmpty { null } + private fun isBuildManifest(path: String): Boolean = path.substringAfterLast('/').lowercase() in BUILD_MANIFEST_FILENAMES diff --git a/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/PlanGrounderTest.kt b/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/PlanGrounderTest.kt index 9bc4ff3c..5a255818 100644 --- a/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/PlanGrounderTest.kt +++ b/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/PlanGrounderTest.kt @@ -89,6 +89,34 @@ class PlanGrounderTest { assertEquals(PlanGroundingVerdict.PASS, r.verdict) } + @Test + fun `a file-writing scaffold stage grounds its own scope, a later scoped stage, and a build`() { + // Repro session d038468a: scaffold stage runs `npm create vite` (file_write + shell) scoped to + // frontend/, creating package.json + the tree at run time — invisible to declared writes/ + // expectedFiles. Grounding must credit that scope, or it false-rejects a plan that follows the + // "use the real scaffolder" guidance (frontend/ "doesn't exist", verify stage has "no manifest"). + val g = graph( + "scaffold" to StageConfig(allowedTools = setOf("shell", "file_write"), touches = listOf("frontend/")), + "design" to StageConfig(touches = listOf("frontend/**")), + "verify" to StageConfig(buildExpectation = BuildExpectation.PROJECT), + ) + val r = PlanGrounder.ground(g, repoMapPaths = emptySet(), profileCommands = cmds) + assertEquals(PlanGroundingVerdict.PASS, r.verdict, "findings: ${r.findings}") + } + + @Test + fun `a read-only shell stage does NOT credit its scope (no file_write, creates nothing)`() { + // A stage with shell for log inspection / test runs — not scaffolding — writes nothing, so its + // scope must not satisfy a build's manifest prerequisite. Keeps the credit keyed on create-intent. + val g = graph( + "inspect" to StageConfig(allowedTools = setOf("shell"), touches = listOf("logs/")), + "verify" to StageConfig(buildExpectation = BuildExpectation.PROJECT), + ) + val r = PlanGrounder.ground(g, repoMapPaths = emptySet(), profileCommands = cmds) + assertEquals(PlanGroundingVerdict.RETURN_TO_ARCHITECT, r.verdict) + assertTrue(r.findings.any { it.contains("verify") && it.contains("no build manifest") }) + } + @Test fun `NONE expectation never grounds`() { val g = graph("impl" to StageConfig(buildExpectation = BuildExpectation.NONE))