From 0a001c42c780cd1ce3b873c8d70eac7199a4d0f2 Mon Sep 17 00:00:00 2001 From: kami Date: Sun, 19 Jul 2026 17:32:48 +0400 Subject: [PATCH] fix(toolintent): manifest gate defers to task affected_paths; scaffold-glob guidance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../rules/ManifestContainmentRule.kt | 30 +++++++++-- .../toolintent/ManifestContainmentRuleTest.kt | 53 +++++++++++++++---- .../workflows/prompts/architect_freestyle.md | 7 ++- 3 files changed, 73 insertions(+), 17 deletions(-) diff --git a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/ManifestContainmentRule.kt b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/ManifestContainmentRule.kt index a9cbcd2e..46782667 100644 --- a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/ManifestContainmentRule.kt +++ b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/ManifestContainmentRule.kt @@ -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() val observations = mutableListOf() @@ -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) diff --git a/core/toolintent/src/test/kotlin/com/correx/core/toolintent/ManifestContainmentRuleTest.kt b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/ManifestContainmentRuleTest.kt index 1c3fbe8a..0cc8c91f 100644 --- a/core/toolintent/src/test/kotlin/com/correx/core/toolintent/ManifestContainmentRuleTest.kt +++ b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/ManifestContainmentRuleTest.kt @@ -24,17 +24,20 @@ class ManifestContainmentRuleTest { override fun resolveReal(path: Path): Path = path.toAbsolutePath().normalize() } - private fun input(pathArg: String, manifest: List) = ToolCallAssessmentInput( - request = ToolRequest( - ToolInvocationId("i"), SessionId("s"), StageId("st"), "file_write", - mapOf("path" to pathArg, "mode" to "0644"), - ), - capabilities = setOf(ToolCapability.FILE_WRITE), - workspace = WorkspacePolicy(workspace), - probe = FakeProbe(), - paramRoles = mapOf("path" to ParamRole.PATH), - writeManifest = manifest, - ) + private fun input(pathArg: String, manifest: List, taskScope: List = emptyList()) = + ToolCallAssessmentInput( + request = ToolRequest( + ToolInvocationId("i"), SessionId("s"), StageId("st"), "file_write", + mapOf("path" to pathArg, "mode" to "0644"), + ), + capabilities = setOf(ToolCapability.FILE_WRITE), + workspace = WorkspacePolicy(workspace), + probe = FakeProbe(), + paramRoles = mapOf("path" to ParamRole.PATH), + writeManifest = manifest, + session = if (taskScope.isEmpty()) SessionContext() + else SessionContext(activeTask = ActiveTask("t1", taskScope)), + ) @Test fun `applies only to write capability`() { @@ -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/**"))) diff --git a/examples/workflows/prompts/architect_freestyle.md b/examples/workflows/prompts/architect_freestyle.md index aa2000f6..144568d3 100644 --- a/examples/workflows/prompts/architect_freestyle.md +++ b/examples/workflows/prompts/architect_freestyle.md @@ -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