feat(tasks): native task tracking — aggregate, agent tools, context bundle

Adds task tracking as a first-class event-sourced aggregate (ADR-0012). The
event log stays the only source of truth; the board is a projection, and all of
a project's task events live in one stream (tasks:<projectId>).

core:
- new core:tasks module mirroring core:sessions: TaskStatus/State/Reducer/
  BoardProjector/CounterProjection/Repository + TaskService write path that
  appends events via the existing EventStore (no new storage)
- task events in core:events (sealed marker TaskEvent), registered in
  Serialization.kt; status is derived by the reducer from lifecycle facts
- work-graph edges: TaskLinkType + typed TaskTargetKind on the link, so bundle
  resolution dispatches on the recorded kind instead of guessing from the id
- delete is a soft tombstone (TaskDeletedEvent); cancel stays a visible outcome

surfaces:
- agent tools (Tier T2 mutators, T1 read): task_create / task_update /
  task_delete / task_context, registered in both the main and per-workspace
  tool registries
- REST GET /tasks/{id}/context for external agents

context bundle (TaskContextAssembler):
- task fields + acceptance criteria + relevant files + resolved dependency
  tasks (live status) + raw related links + notes, with a compact render()
- semantic enrichment via a TaskKnowledgeRetriever port bridged to the existing
  L3 retriever (late-bound holder for composition-root ordering)
- ADR/doc resolution via a TaskDocumentResolver port + filesystem adapter
  (docs/decisions/adr-*.md, padding-agnostic; *.md paths)

Unit-verified across core:tasks / infrastructure:tools / apps:server; full
gradlew check green. Not yet live-QA'd end-to-end against a running server.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 19:00:58 +00:00
parent 7909357747
commit 0c6ac903b7
51 changed files with 2431 additions and 2 deletions
@@ -0,0 +1,142 @@
package com.correx.core.events.events
import com.correx.core.events.types.ProjectId
import com.correx.core.events.types.TaskId
import com.correx.core.events.types.TaskLinkType
import com.correx.core.events.types.TaskNoteAuthor
import com.correx.core.events.types.TaskTargetKind
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Marker over every task-lifecycle event. Deliberately *not* part of the serialized
* [EventPayload] hierarchy — concrete events implement both interfaces — so projections
* can recover the owning [taskId] from any payload via `payload as? TaskEvent` without a
* giant `when`. Status is never carried on the wire: it is derived by the task reducer
* from these facts (mirroring how session status is derived from stage events).
*/
sealed interface TaskEvent {
val taskId: TaskId
}
@Serializable
@SerialName("TaskCreated")
data class TaskCreatedEvent(
override val taskId: TaskId,
val projectId: ProjectId,
val key: String,
val title: String,
val goal: String,
val acceptanceCriteria: List<String> = emptyList(),
val affectedPaths: List<String> = emptyList(),
) : EventPayload, TaskEvent
@Serializable
@SerialName("TaskClaimed")
data class TaskClaimedEvent(
override val taskId: TaskId,
val claimant: String,
) : EventPayload, TaskEvent
@Serializable
@SerialName("TaskReleased")
data class TaskReleasedEvent(
override val taskId: TaskId,
) : EventPayload, TaskEvent
@Serializable
@SerialName("TaskBlocked")
data class TaskBlockedEvent(
override val taskId: TaskId,
val reason: String,
) : EventPayload, TaskEvent
@Serializable
@SerialName("TaskUnblocked")
data class TaskUnblockedEvent(
override val taskId: TaskId,
) : EventPayload, TaskEvent
@Serializable
@SerialName("TaskSubmittedForReview")
data class TaskSubmittedForReviewEvent(
override val taskId: TaskId,
) : EventPayload, TaskEvent
@Serializable
@SerialName("TaskCompleted")
data class TaskCompletedEvent(
override val taskId: TaskId,
) : EventPayload, TaskEvent
@Serializable
@SerialName("TaskReopened")
data class TaskReopenedEvent(
override val taskId: TaskId,
) : EventPayload, TaskEvent
@Serializable
@SerialName("TaskCancelled")
data class TaskCancelledEvent(
override val taskId: TaskId,
val reason: String? = null,
) : EventPayload, TaskEvent
@Serializable
@SerialName("TaskEdited")
data class TaskEditedEvent(
override val taskId: TaskId,
val title: String? = null,
val goal: String? = null,
) : EventPayload, TaskEvent
@Serializable
@SerialName("TaskAcceptanceCriteriaSet")
data class TaskAcceptanceCriteriaSetEvent(
override val taskId: TaskId,
val criteria: List<String>,
) : EventPayload, TaskEvent
@Serializable
@SerialName("TaskAffectedPathsSet")
data class TaskAffectedPathsSetEvent(
override val taskId: TaskId,
val paths: List<String>,
) : EventPayload, TaskEvent
@Serializable
@SerialName("TaskLinked")
data class TaskLinkedEvent(
override val taskId: TaskId,
val targetId: String,
// `linkType`, not `type`: the JSON polymorphic class discriminator is "type".
@SerialName("linkType") val type: TaskLinkType,
val targetKind: TaskTargetKind,
) : EventPayload, TaskEvent
@Serializable
@SerialName("TaskUnlinked")
data class TaskUnlinkedEvent(
override val taskId: TaskId,
val targetId: String,
@SerialName("linkType") val type: TaskLinkType,
) : EventPayload, TaskEvent
@Serializable
@SerialName("TaskNoteAdded")
data class TaskNoteAddedEvent(
override val taskId: TaskId,
val author: TaskNoteAuthor,
val body: String,
) : EventPayload, TaskEvent
/**
* Soft-delete tombstone. The event stays in the log (history is never rewritten); the board
* projection drops the task from active views. Distinct from cancellation, which is a
* lifecycle outcome that stays visible as CANCELLED.
*/
@Serializable
@SerialName("TaskDeleted")
data class TaskDeletedEvent(
override val taskId: TaskId,
) : EventPayload, TaskEvent
@@ -72,6 +72,22 @@ import com.correx.core.events.events.WorkflowCompletedEvent
import com.correx.core.events.events.WorkflowFailedEvent
import com.correx.core.events.events.WorkflowProposedEvent
import com.correx.core.events.events.WorkflowStartedEvent
import com.correx.core.events.events.TaskCreatedEvent
import com.correx.core.events.events.TaskClaimedEvent
import com.correx.core.events.events.TaskReleasedEvent
import com.correx.core.events.events.TaskBlockedEvent
import com.correx.core.events.events.TaskUnblockedEvent
import com.correx.core.events.events.TaskSubmittedForReviewEvent
import com.correx.core.events.events.TaskCompletedEvent
import com.correx.core.events.events.TaskReopenedEvent
import com.correx.core.events.events.TaskCancelledEvent
import com.correx.core.events.events.TaskEditedEvent
import com.correx.core.events.events.TaskAcceptanceCriteriaSetEvent
import com.correx.core.events.events.TaskAffectedPathsSetEvent
import com.correx.core.events.events.TaskLinkedEvent
import com.correx.core.events.events.TaskUnlinkedEvent
import com.correx.core.events.events.TaskNoteAddedEvent
import com.correx.core.events.events.TaskDeletedEvent
import kotlinx.serialization.json.Json
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.polymorphic
@@ -151,6 +167,22 @@ val eventModule = SerializersModule {
subclass(LowQualityExtractionEvent::class)
subclass(EgressHostsGrantedEvent::class)
subclass(StaticFindingsRecordedEvent::class)
subclass(TaskCreatedEvent::class)
subclass(TaskClaimedEvent::class)
subclass(TaskReleasedEvent::class)
subclass(TaskBlockedEvent::class)
subclass(TaskUnblockedEvent::class)
subclass(TaskSubmittedForReviewEvent::class)
subclass(TaskCompletedEvent::class)
subclass(TaskReopenedEvent::class)
subclass(TaskCancelledEvent::class)
subclass(TaskEditedEvent::class)
subclass(TaskAcceptanceCriteriaSetEvent::class)
subclass(TaskAffectedPathsSetEvent::class)
subclass(TaskLinkedEvent::class)
subclass(TaskUnlinkedEvent::class)
subclass(TaskNoteAddedEvent::class)
subclass(TaskDeletedEvent::class)
}
}
@@ -19,6 +19,10 @@ typealias TransitionId = TypeId
typealias ArtifactId = TypeId
// Tasks types
typealias TaskId = TypeId
// Context types
typealias ContextPackId = TypeId
typealias ContextEntryId = TypeId
@@ -0,0 +1,18 @@
package com.correx.core.events.types
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Edge types for the work graph. A task links to other tasks, artifacts (by [ArtifactId]),
* or sessions (by [SessionId]) — the target is stored as a plain id string plus a type.
*/
@Serializable
enum class TaskLinkType {
@SerialName("DEPENDS_ON") DEPENDS_ON,
@SerialName("BLOCKS") BLOCKS,
@SerialName("IMPLEMENTS") IMPLEMENTS,
@SerialName("RELATES_TO") RELATES_TO,
@SerialName("PRODUCED") PRODUCED,
@SerialName("CONTEXT") CONTEXT,
}
@@ -0,0 +1,11 @@
package com.correx.core.events.types
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** Who authored a task note — used to keep the agent and human lanes separable. */
@Serializable
enum class TaskNoteAuthor {
@SerialName("AGENT") AGENT,
@SerialName("HUMAN") HUMAN,
}
@@ -0,0 +1,16 @@
package com.correx.core.events.types
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* What a task link points at — recorded on the link so the context bundle dispatches resolution
* deterministically (task lookup vs doc resolution vs left-raw) instead of inferring from the id.
*/
@Serializable
enum class TaskTargetKind {
@SerialName("TASK") TASK,
@SerialName("DOC") DOC,
@SerialName("ARTIFACT") ARTIFACT,
@SerialName("SESSION") SESSION,
}