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.
- package/LICENSE +21 -0
- package/README.md +141 -0
- package/hooks/need-info-notify/config.yml +3 -0
- package/hooks/need-info-notify/script.ts +69 -0
- package/hooks/review-spawn/config.yml +3 -0
- package/hooks/review-spawn/script.ts +93 -0
- package/package.json +46 -0
- package/src/domain/fibonacci.ts +39 -0
- package/src/domain/kTokens.ts +65 -0
- package/src/domain/status-machine.ts +38 -0
- package/src/domain/types.ts +56 -0
- package/src/hook/hook-executor.ts +70 -0
- package/src/hook/ports.ts +16 -0
- package/src/infra/hook-config-loader.ts +99 -0
- package/src/infra/jsonl-task-repository.ts +156 -0
- package/src/mcp/error-codes.ts +28 -0
- package/src/mcp/server.ts +214 -0
- package/src/mcp/session.ts +1 -0
- package/src/mcp/tool-create-task.ts +27 -0
- package/src/mcp/tool-current-task.ts +15 -0
- package/src/mcp/tool-edit-task.ts +35 -0
- package/src/mcp/tool-list-tasks.ts +22 -0
- package/src/mcp/tool-update-task.ts +47 -0
- package/src/task/create-task.ts +71 -0
- package/src/task/current-task.ts +24 -0
- package/src/task/edit-task.ts +58 -0
- package/src/task/list-tasks.ts +12 -0
- package/src/task/ports.ts +15 -0
- package/src/task/update-task.ts +116 -0
|
@@ -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
|
+
})
|