logbook-mcp 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,71 @@
1
+ import { Effect } from "effect"
2
+ import { estimateFromKTokens } from "../domain/kTokens.js"
3
+ import type { Task, TaskError } from "../domain/types.js"
4
+ import { TaskRepository } from "./ports.js"
5
+
6
+ export interface CreateTaskInput {
7
+ project: string
8
+ milestone: string
9
+ title: string
10
+ definition_of_done: string
11
+ description: string
12
+ predictedKTokens: number
13
+ }
14
+
15
+ /**
16
+ * Creates a new task in `backlog` status assigned to `sessionId`.
17
+ * Validates all fields and derives a Fibonacci estimation from predictedKTokens.
18
+ */
19
+ export const createTask = (
20
+ input: CreateTaskInput,
21
+ sessionId: string
22
+ ): Effect.Effect<Task, TaskError, TaskRepository> => {
23
+ // Validate required string fields
24
+ const requiredStringFields: Array<keyof CreateTaskInput> = [
25
+ "project",
26
+ "milestone",
27
+ "title",
28
+ "definition_of_done",
29
+ "description",
30
+ ]
31
+
32
+ for (const field of requiredStringFields) {
33
+ if (typeof input[field] !== "string" || input[field] === "") {
34
+ return Effect.fail({
35
+ _tag: "validation_error" as const,
36
+ message: `${field} is required`,
37
+ })
38
+ }
39
+ }
40
+
41
+ // Validate predictedKTokens is defined and a number
42
+ if (input.predictedKTokens === undefined || input.predictedKTokens === null) {
43
+ return Effect.fail({
44
+ _tag: "validation_error" as const,
45
+ message: "predictedKTokens is required",
46
+ })
47
+ }
48
+
49
+ // Derive Fibonacci estimation from kTokens
50
+ return Effect.flatMap(estimateFromKTokens(input.predictedKTokens), (estimation) => {
51
+ const id = crypto.randomUUID()
52
+ const task: Task = {
53
+ project: input.project,
54
+ milestone: input.milestone,
55
+ id,
56
+ title: input.title,
57
+ definition_of_done: input.definition_of_done,
58
+ description: input.description,
59
+ estimation,
60
+ comments: [],
61
+ assignee: {
62
+ id: sessionId,
63
+ title: "Agent",
64
+ description: "",
65
+ },
66
+ status: "backlog" as const,
67
+ }
68
+
69
+ return Effect.flatMap(TaskRepository, (repo) => repo.save(task)).pipe(Effect.map(() => task))
70
+ })
71
+ }
@@ -0,0 +1,24 @@
1
+ import { Effect } from "effect"
2
+ import type { Task, TaskError } from "../domain/types.js"
3
+ import { TaskRepository } from "./ports.js"
4
+
5
+ /**
6
+ * Returns the oldest in_progress task assigned to the given session (FIFO by in_progress_since).
7
+ * Fails with `no_current_task` when the session has no in_progress tasks.
8
+ */
9
+ export const currentTask = (sessionId: string): Effect.Effect<Task, TaskError, TaskRepository> =>
10
+ Effect.flatMap(TaskRepository, (repo) =>
11
+ Effect.flatMap(repo.findByStatus("in_progress"), (tasks) => {
12
+ const assignedTasks = tasks.filter((t) => t.assignee.id === sessionId)
13
+ const sorted = [...assignedTasks].sort((a, b) => {
14
+ // Tasks without in_progress_since go last
15
+ const aTime = a.in_progress_since?.getTime() ?? Infinity
16
+ const bTime = b.in_progress_since?.getTime() ?? Infinity
17
+ return aTime - bTime
18
+ })
19
+ const first = sorted[0]
20
+ return first !== undefined
21
+ ? Effect.succeed(first)
22
+ : Effect.fail({ _tag: "no_current_task" as const })
23
+ })
24
+ )
@@ -0,0 +1,58 @@
1
+ import { Effect } from "effect"
2
+ import { estimateFromKTokens } from "../domain/kTokens.js"
3
+ import type { Task, TaskError } from "../domain/types.js"
4
+ import { TaskRepository } from "./ports.js"
5
+
6
+ export interface EditTaskInput {
7
+ title?: string
8
+ description?: string
9
+ definition_of_done?: string
10
+ predictedKTokens?: number
11
+ }
12
+
13
+ /**
14
+ * Edits mutable fields of an existing task without changing its status.
15
+ * Derives Fibonacci estimation from predictedKTokens when provided.
16
+ * Fails with `not_found` for unknown id.
17
+ * Fails with `validation_error` when a `status` field is attempted via EditTaskInput.
18
+ */
19
+ export const editTask = (
20
+ id: string,
21
+ updates: EditTaskInput
22
+ ): Effect.Effect<Task, TaskError, TaskRepository> => {
23
+ // Check for attempted status modification (runtime guard against type system bypass)
24
+ if ("status" in updates) {
25
+ return Effect.fail({
26
+ _tag: "validation_error" as const,
27
+ message: "status field cannot be edited",
28
+ })
29
+ }
30
+
31
+ // Derive estimation from predictedKTokens if present
32
+ if (updates.predictedKTokens !== undefined) {
33
+ return Effect.flatMap(estimateFromKTokens(updates.predictedKTokens), (estimation) =>
34
+ Effect.flatMap(TaskRepository, (repo) =>
35
+ Effect.flatMap(repo.findById(id), (task) => {
36
+ const { predictedKTokens: _, ...rest } = updates
37
+ const updatedTask: Task = {
38
+ ...task,
39
+ ...rest,
40
+ estimation,
41
+ }
42
+ return Effect.flatMap(repo.update(updatedTask), () => Effect.succeed(updatedTask))
43
+ })
44
+ )
45
+ )
46
+ }
47
+
48
+ // No estimation to derive, proceed directly with find and update
49
+ return Effect.flatMap(TaskRepository, (repo) =>
50
+ Effect.flatMap(repo.findById(id), (task) => {
51
+ const updatedTask: Task = {
52
+ ...task,
53
+ ...updates,
54
+ }
55
+ return Effect.flatMap(repo.update(updatedTask), () => Effect.succeed(updatedTask))
56
+ })
57
+ )
58
+ }
@@ -0,0 +1,12 @@
1
+ import { Effect } from "effect"
2
+ import type { Status, Task, TaskError } from "../domain/types.js"
3
+ import { TaskRepository } from "./ports.js"
4
+
5
+ /**
6
+ * Returns tasks matching the given status, or all tasks when status is '*'.
7
+ * Fails with `validation_error` when the underlying data is malformed.
8
+ */
9
+ export const listTasks = (
10
+ status: Status | "*"
11
+ ): Effect.Effect<readonly Task[], TaskError, TaskRepository> =>
12
+ Effect.flatMap(TaskRepository, (repo) => repo.findByStatus(status))
@@ -0,0 +1,15 @@
1
+ import { Context, type Effect } from "effect"
2
+ import type { Status, Task, TaskError } from "../domain/types.js"
3
+
4
+ export interface TaskRepository {
5
+ /** Fails with `not_found` if id is absent. */
6
+ findById(id: string): Effect.Effect<Task, TaskError>
7
+ /** Returns empty array when nothing matches; fails with `validation_error` on malformed data. */
8
+ findByStatus(status: Status | "*"): Effect.Effect<readonly Task[], TaskError>
9
+ /** Fails with `conflict` if a task with the same id already exists. */
10
+ save(task: Task): Effect.Effect<void, TaskError>
11
+ /** Fails with `not_found` if id is absent. */
12
+ update(task: Task): Effect.Effect<void, TaskError>
13
+ }
14
+
15
+ export const TaskRepository = Context.GenericTag<TaskRepository>("TaskRepository")
@@ -0,0 +1,116 @@
1
+ import { Effect } from "effect"
2
+ import { guardTransition } from "../domain/status-machine.js"
3
+ import type { Comment, Status, TaskError } from "../domain/types.js"
4
+ import { HookRunner } from "../hook/ports.js"
5
+ import { TaskRepository } from "./ports.js"
6
+
7
+ /**
8
+ * Transitions a task to a new status, optionally attaching or replying to a comment.
9
+ * Enforces transition rules, comment requirements, need_info reply cycle,
10
+ * and concurrent in_progress justification.
11
+ * Fires HookRunner after a successful status change.
12
+ */
13
+ export const updateTask = (
14
+ id: string,
15
+ newStatus: Status,
16
+ comment: Comment | null,
17
+ sessionId: string
18
+ ): Effect.Effect<void, TaskError, TaskRepository | HookRunner> =>
19
+ Effect.gen(function* () {
20
+ const repo = yield* TaskRepository
21
+ const hookRunner = yield* HookRunner
22
+
23
+ // Step 1: find task or fail with not_found
24
+ const task = yield* repo.findById(id)
25
+
26
+ // Step 2: guard transition (same→same is allowed by guardTransition too)
27
+ yield* guardTransition(task.status, newStatus)
28
+
29
+ // Step 4: reply handling — comment id matches an existing comment
30
+ // Must run before the no-op check because a reply update is meaningful
31
+ // even when the status is not changing.
32
+ if (comment !== null) {
33
+ const existing = task.comments.find((c) => c.id === comment.id)
34
+ if (existing !== undefined) {
35
+ if (existing.kind === "regular") {
36
+ return yield* Effect.fail<TaskError>({
37
+ _tag: "validation_error",
38
+ message: "reply is only valid on need_info comments",
39
+ })
40
+ }
41
+ // existing.kind === 'need_info': merge reply and persist, no hook, no status change
42
+ const updatedComments = task.comments.map((c) =>
43
+ c.id === comment.id ? { ...c, reply: comment.reply } : c
44
+ )
45
+ const updatedTask = { ...task, comments: updatedComments }
46
+ yield* repo.update(updatedTask)
47
+ return
48
+ }
49
+ }
50
+
51
+ // Step 3: no-op when status unchanged (and no reply was handled above)
52
+ if (task.status === newStatus) return
53
+
54
+ // Step 5: need_info requires a comment
55
+ if (newStatus === "need_info" && comment === null) {
56
+ return yield* Effect.fail<TaskError>({ _tag: "missing_comment" })
57
+ }
58
+
59
+ // Step 6: blocked requires a non-empty comment
60
+ if (newStatus === "blocked") {
61
+ if (comment === null) {
62
+ return yield* Effect.fail<TaskError>({ _tag: "missing_comment" })
63
+ }
64
+ if (comment.content.trim() === "") {
65
+ return yield* Effect.fail<TaskError>({
66
+ _tag: "validation_error",
67
+ message: "blocked requires a non-empty comment",
68
+ })
69
+ }
70
+ }
71
+
72
+ // Step 7: transitioning FROM need_info — all need_info comments must have a reply
73
+ if (task.status === "need_info") {
74
+ const blocking = task.comments.find((c) => c.kind === "need_info" && c.reply === "")
75
+ if (blocking !== undefined) {
76
+ return yield* Effect.fail<TaskError>({
77
+ _tag: "validation_error",
78
+ message: `blocking comment ${blocking.id} has no reply`,
79
+ })
80
+ }
81
+ }
82
+
83
+ // Step 8: concurrent in_progress — second task for same session requires justification
84
+ if (newStatus === "in_progress") {
85
+ const inProgressTasks = yield* repo.findByStatus("in_progress")
86
+ const sessionInProgress = inProgressTasks.filter(
87
+ (t) => t.assignee.id === sessionId && t.id !== task.id
88
+ )
89
+ if (sessionInProgress.length > 0) {
90
+ if (comment === null || comment.content.trim() === "") {
91
+ return yield* Effect.fail<TaskError>({
92
+ _tag: "validation_error",
93
+ message: "moving a second task to in_progress requires a justification comment",
94
+ })
95
+ }
96
+ }
97
+ }
98
+
99
+ // Step 9: apply changes
100
+ const oldStatus = task.status
101
+ const updatedComments = comment !== null ? [...task.comments, comment] : task.comments
102
+ const updatedTask = {
103
+ ...task,
104
+ status: newStatus,
105
+ comments: updatedComments,
106
+ ...(newStatus === "in_progress" ? { in_progress_since: new Date() } : {}),
107
+ }
108
+ yield* repo.update(updatedTask)
109
+ yield* hookRunner.run({
110
+ task_id: id,
111
+ old_status: oldStatus,
112
+ new_status: newStatus,
113
+ comment,
114
+ session_id: sessionId,
115
+ })
116
+ })