fix(plan-grounding): credit a file-writing stage's scope as will-exist

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Ly2mMnt9TCZbvhcC1JfuV
This commit is contained in:
2026-07-19 23:24:28 +04:00
parent b43ab77eee
commit 9ac32c9e93
2 changed files with 65 additions and 5 deletions
@@ -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<String>, createdPaths: List<String>): 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<String>,
createdPaths: List<String>,
writeScopes: List<String>,
): 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
@@ -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))