fix(toolintent): manifest gate defers to task affected_paths; scaffold-glob guidance
Two failed web-ui freestyle runs dead-ended on the write manifest. The scaffold_frontend stage declared writes=[frontend/package.json, frontend/vite.config.ts], so every other file the scaffold produced (tsconfig, src/main.tsx, index.html, App.tsx) was BLOCKED as PATH_OUTSIDE_MANIFEST with no agent-facing escape hatch — unlike WRITE_SCOPE, which advertises task_update. Retry exhausted -> WorkflowFailed. - ManifestContainmentRule: a write already inside the active task's affected_paths is allowed even when the stage manifest is narrower. The task scope is the agent-widenable, recorded authority (see WriteScopeRule); the stage manifest is a planner hint that defers to it. Block message now names the remedy (widen affected_paths via task_update). - architect_freestyle prompt: a scaffold/generator stage must declare its `writes` as a covering directory glob (frontend/**), not enumerate files, and use ** not * — frontend/* does not cover frontend/src/main.tsx. core:toolintent green (78 tests), detekt clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+25
-5
@@ -19,8 +19,11 @@ import java.nio.file.Path
|
||||
* workspace but outside the declared set is scope creep — the dominant local-implementer
|
||||
* failure that tests do not catch — and is raised as PATH_OUTSIDE_MANIFEST → BLOCK.
|
||||
*
|
||||
* Empty manifest = unrestricted (workspace containment still applies via
|
||||
* [PathContainmentRule]). Out-of-workspace targets are that rule's concern, not this one.
|
||||
* A write already inside the active task's affected_paths is allowed even if the manifest is
|
||||
* narrower: those paths are the agent-widenable, recorded authority (see [WriteScopeRule]), and
|
||||
* the stage manifest is a planner hint that defers to them. Empty manifest = unrestricted
|
||||
* (workspace containment still applies via [PathContainmentRule]). Out-of-workspace targets are
|
||||
* that rule's concern, not this one.
|
||||
* Path resolution goes through WorldProbe (symlink-safe) and the facts are recorded as
|
||||
* observations, so replay reads them back rather than re-globbing (invariant #9).
|
||||
*/
|
||||
@@ -36,6 +39,14 @@ class ManifestContainmentRule : ToolCallRule {
|
||||
val matchers = input.writeManifest.map {
|
||||
FileSystems.getDefault().getPathMatcher("glob:$it")
|
||||
}
|
||||
// The active task's affected_paths are the agent-widenable, recorded authority (see
|
||||
// WriteScopeRule). A write already inside that scope is not manifest scope-creep — the
|
||||
// stage manifest is a narrower planner hint that must defer to it, otherwise a too-tight
|
||||
// manifest becomes an unrecoverable dead-end (the escape hatch is task_update affected_paths).
|
||||
val taskScope = input.session.activeTask?.scope.orEmpty()
|
||||
val taskMatchers = taskScope.map {
|
||||
FileSystems.getDefault().getPathMatcher("glob:${it.removePrefix("./")}")
|
||||
}
|
||||
|
||||
val issues = mutableListOf<ValidationIssue>()
|
||||
val observations = mutableListOf<ToolCallObservation>()
|
||||
@@ -48,7 +59,9 @@ class ManifestContainmentRule : ToolCallRule {
|
||||
val resolvedReal = input.probe.resolveReal(resolvedInput)
|
||||
val inWorkspace = resolvedReal.startsWith(workspaceReal)
|
||||
val relative = if (inWorkspace) workspaceReal.relativize(resolvedReal).toString() else raw
|
||||
val inManifest = inWorkspace && matchers.any { it.matches(Path.of(relative)) }
|
||||
val relPath = Path.of(relative)
|
||||
val inManifest = inWorkspace && matchers.any { it.matches(relPath) }
|
||||
val inTaskScope = inWorkspace && taskMatchers.any { it.matches(relPath) }
|
||||
|
||||
observations += ToolCallObservation(
|
||||
ruleCode = RULE_CODE,
|
||||
@@ -57,14 +70,21 @@ class ManifestContainmentRule : ToolCallRule {
|
||||
"relative" to relative,
|
||||
"inWorkspace" to inWorkspace.toString(),
|
||||
"inManifest" to inManifest.toString(),
|
||||
"inTaskScope" to inTaskScope.toString(),
|
||||
),
|
||||
)
|
||||
|
||||
if (inWorkspace && !inManifest) {
|
||||
if (inWorkspace && !inManifest && !inTaskScope) {
|
||||
val remedy = if (taskScope.isEmpty()) {
|
||||
" Claim a task whose affected_paths cover this path (task_update), then retry."
|
||||
} else {
|
||||
" If this path is genuinely part of the task, widen its affected_paths via " +
|
||||
"task_update (note why), then retry."
|
||||
}
|
||||
issues += ValidationIssue(
|
||||
code = "PATH_OUTSIDE_MANIFEST",
|
||||
message = "Tool '${input.request.toolName}' writes '$raw' outside the stage's " +
|
||||
"declared manifest ${input.writeManifest}",
|
||||
"declared manifest ${input.writeManifest}.$remedy",
|
||||
severity = ValidationSeverity.ERROR,
|
||||
)
|
||||
disposition = maxAction(disposition, RiskAction.BLOCK)
|
||||
|
||||
+32
-1
@@ -24,7 +24,8 @@ class ManifestContainmentRuleTest {
|
||||
override fun resolveReal(path: Path): Path = path.toAbsolutePath().normalize()
|
||||
}
|
||||
|
||||
private fun input(pathArg: String, manifest: List<String>) = ToolCallAssessmentInput(
|
||||
private fun input(pathArg: String, manifest: List<String>, taskScope: List<String> = emptyList()) =
|
||||
ToolCallAssessmentInput(
|
||||
request = ToolRequest(
|
||||
ToolInvocationId("i"), SessionId("s"), StageId("st"), "file_write",
|
||||
mapOf("path" to pathArg, "mode" to "0644"),
|
||||
@@ -34,6 +35,8 @@ class ManifestContainmentRuleTest {
|
||||
probe = FakeProbe(),
|
||||
paramRoles = mapOf("path" to ParamRole.PATH),
|
||||
writeManifest = manifest,
|
||||
session = if (taskScope.isEmpty()) SessionContext()
|
||||
else SessionContext(activeTask = ActiveTask("t1", taskScope)),
|
||||
)
|
||||
|
||||
@Test
|
||||
@@ -67,6 +70,34 @@ class ManifestContainmentRuleTest {
|
||||
assertEquals("false", r.observations.single().facts["inManifest"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `write outside manifest but inside task scope is allowed`() {
|
||||
// The scaffold dead-end: manifest lists only two files, but the claimed task's
|
||||
// affected_paths cover the whole subtree. The recorded task scope wins.
|
||||
val r = rule.assess(
|
||||
input(
|
||||
"/work/project/frontend/src/main.tsx",
|
||||
manifest = listOf("frontend/package.json", "frontend/vite.config.ts"),
|
||||
taskScope = listOf("frontend/**"),
|
||||
),
|
||||
)
|
||||
assertEquals(RiskAction.PROCEED, r.disposition)
|
||||
assertTrue(r.issues.isEmpty())
|
||||
val facts = r.observations.single().facts
|
||||
assertEquals("false", facts["inManifest"])
|
||||
assertEquals("true", facts["inTaskScope"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `write outside both manifest and task scope is blocked with widen remedy`() {
|
||||
val r = rule.assess(
|
||||
input("/work/project/apps/x.kt", manifest = listOf("core/**"), taskScope = listOf("frontend/**")),
|
||||
)
|
||||
assertEquals(RiskAction.BLOCK, r.disposition)
|
||||
assertEquals("PATH_OUTSIDE_MANIFEST", r.issues.single().code)
|
||||
assertTrue(r.issues.single().message.contains("task_update"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `relative path arg is resolved against the workspace before matching`() {
|
||||
val r = rule.assess(input("core/a/B.kt", listOf("core/**")))
|
||||
|
||||
@@ -103,7 +103,12 @@ Emit a JSON object that validates against the `execution_plan` schema:
|
||||
always pass the template/preset arg the generator requires (`-- --template react-ts`) since
|
||||
there's no prompt to fall back on. `file_write` is the fallback when no generator fits the
|
||||
stack. Only bare remote runners (`npx`, `bunx`, `pnpx`) stay blocked — they execute arbitrary
|
||||
remote code; reach them via `npm create` instead.
|
||||
remote code; reach them via `npm create` instead. A scaffold/generator stage emits an
|
||||
open-ended file set (a whole `frontend/src/**` tree, not two named files), so declare its
|
||||
`writes` as the covering directory glob — `["frontend/**"]`, **not** an enumerated
|
||||
`["frontend/package.json", "frontend/vite.config.ts"]`. A too-narrow `writes` becomes the
|
||||
stage's write manifest and blocks every file the generator legitimately produces. Use `**`
|
||||
(recursive), not `*` (one level) — `frontend/*` does not cover `frontend/src/main.tsx`.
|
||||
- **Every stage prompt must describe its exact JSON output structure when using a
|
||||
structured kind.** The model that runs the stage never sees the raw JSON schema — it only sees
|
||||
the kind's name (e.g. `analysis`, `research_report`, `review_report`) and the prompt you write
|
||||
|
||||
Reference in New Issue
Block a user