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:
+37
-5
@@ -47,9 +47,23 @@ object PlanGrounder {
|
|||||||
val manifestProducedByPlan = graph.stages.values
|
val manifestProducedByPlan = graph.stages.values
|
||||||
.flatMap { it.writeManifest + it.expectedFiles }
|
.flatMap { it.writeManifest + it.expectedFiles }
|
||||||
.any(::isBuildManifest)
|
.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 createdPaths = graph.stages.values.flatMap { it.expectedFiles + it.writeManifest }
|
||||||
|
|
||||||
val findings = graph.stages.entries.flatMap { (id, cfg) ->
|
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.
|
// "scoped to EXISTING frontend/ that isn't there" failure. Caught at stage 0, not stage 11.
|
||||||
if (scanned) {
|
if (scanned) {
|
||||||
cfg.touches
|
cfg.touches
|
||||||
.filter { glob -> !scopeSatisfied(glob, repoMapPaths, createdPaths) }
|
.filter { glob -> !scopeSatisfied(glob, repoMapPaths, createdPaths, writeScopes) }
|
||||||
.forEach { glob ->
|
.forEach { glob ->
|
||||||
add(
|
add(
|
||||||
"stage ${id.value}: scoped to '$glob' but nothing under it exists in the repo map " +
|
"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 {
|
val matcher: PathMatcher = runCatching {
|
||||||
FileSystems.getDefault().getPathMatcher("glob:${glob.trimStart('/')}")
|
FileSystems.getDefault().getPathMatcher("glob:${glob.trimStart('/')}")
|
||||||
}.getOrElse { return true } // ponytail: an unparseable glob is not a grounding failure — don't block on it.
|
}.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)
|
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 =
|
private fun isBuildManifest(path: String): Boolean =
|
||||||
path.substringAfterLast('/').lowercase() in BUILD_MANIFEST_FILENAMES
|
path.substringAfterLast('/').lowercase() in BUILD_MANIFEST_FILENAMES
|
||||||
|
|
||||||
|
|||||||
+28
@@ -89,6 +89,34 @@ class PlanGrounderTest {
|
|||||||
assertEquals(PlanGroundingVerdict.PASS, r.verdict)
|
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
|
@Test
|
||||||
fun `NONE expectation never grounds`() {
|
fun `NONE expectation never grounds`() {
|
||||||
val g = graph("impl" to StageConfig(buildExpectation = BuildExpectation.NONE))
|
val g = graph("impl" to StageConfig(buildExpectation = BuildExpectation.NONE))
|
||||||
|
|||||||
Reference in New Issue
Block a user