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:
2026-07-19 17:32:48 +04:00
parent 68c56b6af6
commit 0a001c42c7
3 changed files with 73 additions and 17 deletions
@@ -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)
@@ -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