diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt index 8bf9a944..38190c3b 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt @@ -63,6 +63,7 @@ import com.correx.core.toolintent.rules.NetworkHostRule import com.correx.core.toolintent.rules.PathContainmentRule import com.correx.core.toolintent.rules.ReadBeforeWriteRule import com.correx.core.toolintent.rules.ReferenceExistsRule +import com.correx.core.toolintent.rules.WriteScopeRule import com.correx.apps.server.freestyle.FreestyleDriver import com.correx.apps.server.inference.summarizeWithInference import com.correx.core.kernel.orchestration.JournalCompactionService @@ -239,6 +240,7 @@ fun main() { taskArtifactResolver, taskSessionResolver, com.correx.apps.server.tasks.EventStoreSessionFactRecorder(eventStore), + com.correx.apps.server.tasks.EventStoreSessionWrites(eventStore), ) val toolRegistry = InfrastructureModule.createToolRegistry( buildToolConfig( @@ -266,6 +268,7 @@ fun main() { PathContainmentRule(), ReadBeforeWriteRule(), ReferenceExistsRule(), + WriteScopeRule(), ManifestContainmentRule(), ExecInterpreterRule(toolsConfig.interpreterExecutables.toSet()), NetworkHostRule( diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/tasks/EventStoreSessionWrites.kt b/apps/server/src/main/kotlin/com/correx/apps/server/tasks/EventStoreSessionWrites.kt new file mode 100644 index 00000000..d6d020ea --- /dev/null +++ b/apps/server/src/main/kotlin/com/correx/apps/server/tasks/EventStoreSessionWrites.kt @@ -0,0 +1,21 @@ +package com.correx.apps.server.tasks + +import com.correx.core.events.stores.EventStore +import com.correx.core.events.types.SessionId +import com.correx.core.toolintent.SessionContextProjection +import com.correx.infrastructure.tools.task.SessionWrites + +/** + * Adapter for the [SessionWrites] port: folds a session's events through + * [SessionContextProjection] and returns its writes (the recorded receipt.affectedEntities). + * Powers the cite-before-claim gate. Replay-safe (reads the log, never live state). + */ +class EventStoreSessionWrites(private val eventStore: EventStore) : SessionWrites { + + override fun pathsWritten(sessionId: SessionId): Set { + val projection = SessionContextProjection(sessionId) + return eventStore.read(sessionId) + .fold(projection.initial()) { acc, event -> projection.apply(acc, event) } + .writes + } +} diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/SessionWrites.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/SessionWrites.kt new file mode 100644 index 00000000..6d82e833 --- /dev/null +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/SessionWrites.kt @@ -0,0 +1,13 @@ +package com.correx.infrastructure.tools.task + +import com.correx.core.events.types.SessionId + +/** + * Port: the paths a session has written this run. The host supplies an adapter that folds the + * session's SessionContext (its writes come from the recorded receipt.affectedEntities). The + * cite-before-claim gate in [TaskUpdateTool] uses it to confirm a task being completed actually saw + * changes under its affected_paths. A null binding leaves the gate off. + */ +fun interface SessionWrites { + fun pathsWritten(sessionId: SessionId): Set +} diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskTools.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskTools.kt index e7460d96..e3103af5 100644 --- a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskTools.kt +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskTools.kt @@ -22,10 +22,11 @@ object TaskTools { artifactResolver: TaskArtifactResolver? = null, sessionResolver: TaskSessionResolver? = null, sessionFacts: SessionFactRecorder? = null, + sessionWrites: SessionWrites? = null, ): List = listOf( TaskCreateTool(service), - TaskUpdateTool(service, sessionFacts), + TaskUpdateTool(service, sessionFacts, sessionWrites), TaskDeleteTool(service), TaskSearchTool(service), TaskReadyTool(service), diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt index 2c6f4c26..571cb1d2 100644 --- a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt @@ -20,14 +20,21 @@ import kotlinx.serialization.json.buildJsonArray import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put import kotlinx.serialization.json.putJsonObject +import java.nio.file.FileSystems +import java.nio.file.Path /** * Agent-facing tool: update an existing task — edit fields, transition status, add a work-graph * link, or attach a note. The task's project is derived from its id, so only the id is required. + * + * [sessionFacts] records the session's active task on claim; [sessionWrites], when bound, powers + * the cite-before-claim gate (completing a task that declared affected_paths but saw no matching + * write this session is blocked, escapable with force). */ class TaskUpdateTool( private val service: TaskService, private val sessionFacts: SessionFactRecorder? = null, + private val sessionWrites: SessionWrites? = null, ) : Tool, ToolExecutor { override val name: String = "task_update" @@ -95,6 +102,7 @@ class TaskUpdateTool( val action = request.stringParam("action") claimBlock(request, taskId, action)?.let { return it } + completeBlock(request, taskId, action)?.let { return it } applyFieldEdits(request, taskId) @@ -133,6 +141,35 @@ class TaskUpdateTool( return fail(request, "Cannot claim ${taskId.value} — unmet blockers: $listed. Complete them, or pass force=true.") } + /** + * Cite-before-claim: refuse to complete a task that declared affected_paths but saw no matching + * write this session — "marked done without doing it." Off when no [sessionWrites] is bound or + * the task declared no scope; escapable with force. + */ + private fun completeBlock(request: ToolRequest, taskId: TaskId, action: String?): ToolResult.Failure? { + if (action != "complete" || request.boolParam("force")) return null + val writes = sessionWrites ?: return null + val scope = service.getTask(taskId)?.state?.affectedPaths.orEmpty() + if (scope.isEmpty()) return null + if (matchesAnyGlob(writes.pathsWritten(request.sessionId), scope)) return null + return fail( + request, + "Cannot complete ${taskId.value} — no changes under its affected_paths ($scope) this " + + "session. Make the change, or pass force=true.", + ) + } + + /** True if any written path matches any affected_paths glob (workspace-relative; './' tolerated). */ + private fun matchesAnyGlob(written: Set, globs: List): Boolean { + if (written.isEmpty()) return false + val fs = FileSystems.getDefault() + val matchers = globs.map { fs.getPathMatcher("glob:${it.removePrefix("./")}") } + return written.any { w -> + val rel = Path.of(w.removePrefix("./")) + matchers.any { it.matches(rel) } + } + } + /** * Record the session→task working relationship (for SessionContext.activeTask) on a claim, or on * an affected_paths edit by the recorded claimant (to refresh the snapshot scope). No-op without diff --git a/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt b/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt index 8a5b5123..e6b395eb 100644 --- a/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt +++ b/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt @@ -228,6 +228,31 @@ class TaskToolsTest { assertEquals(listOf("core/auth/**"), captured.single().third) } + @Test + fun `task_update blocks completing a task with no writes under its affected_paths`() = runBlocking { + create.execute( + request( + "task_create", + mapOf("project" to "auth", "title" to "A", "goal" to "g", "affected_paths" to listOf("core/auth/**")), + ), + ) + val id = "auth-1" + update.execute(request("task_update", mapOf("id" to id, "action" to "claim"))) + update.execute(request("task_update", mapOf("id" to id, "action" to "submit_for_review"))) + + // No matching write this session → complete is blocked, task stays IN_REVIEW. + val noWrites = TaskUpdateTool(service, sessionWrites = SessionWrites { emptySet() }) + val blocked = noWrites.execute(request("task_update", mapOf("id" to id, "action" to "complete"))) + assertTrue(blocked is ToolResult.Failure) + assertEquals(TaskStatus.IN_REVIEW, service.getTask(TaskId(id))!!.state.status) + + // A write under affected_paths → completes. + val withWrites = TaskUpdateTool(service, sessionWrites = SessionWrites { setOf("core/auth/Login.kt") }) + val ok = withWrites.execute(request("task_update", mapOf("id" to id, "action" to "complete"))) + assertTrue(ok is ToolResult.Success) + assertEquals(TaskStatus.DONE, service.getTask(TaskId(id))!!.state.status) + } + @Test fun `task_delete tombstones the task`() = runBlocking { val id = createTask()