@bosun-sh/logbook 1.0.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 +361 -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 +96 -0
- package/package.json +47 -0
- package/src/cli/init.ts +90 -0
- package/src/domain/fibonacci.ts +39 -0
- package/src/domain/kTokens.ts +65 -0
- package/src/domain/status-machine.ts +49 -0
- package/src/domain/types.ts +66 -0
- package/src/hook/hook-executor.ts +70 -0
- package/src/hook/ports.ts +16 -0
- package/src/infra/hook-config-loader.ts +111 -0
- package/src/infra/jsonl-task-repository.ts +157 -0
- package/src/infra/logger.ts +40 -0
- package/src/infra/pid-session-registry.ts +67 -0
- package/src/mcp/error-codes.ts +213 -0
- package/src/mcp/server.ts +413 -0
- package/src/mcp/session.ts +1 -0
- package/src/mcp/tool-create-task.ts +28 -0
- package/src/mcp/tool-current-task.ts +19 -0
- package/src/mcp/tool-edit-task.ts +40 -0
- package/src/mcp/tool-list-tasks.ts +34 -0
- package/src/mcp/tool-update-task.ts +55 -0
- package/src/task/create-task.ts +67 -0
- package/src/task/current-task.ts +111 -0
- package/src/task/edit-task.ts +59 -0
- package/src/task/list-tasks.ts +35 -0
- package/src/task/ports.ts +15 -0
- package/src/task/session-registry.ts +9 -0
- package/src/task/update-task.ts +160 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { allowedTransitions } from "../domain/status-machine.js"
|
|
2
|
+
import type { Status, TaskError } from "../domain/types.js"
|
|
3
|
+
|
|
4
|
+
export interface McpError {
|
|
5
|
+
code: number
|
|
6
|
+
message: string
|
|
7
|
+
data: Record<string, unknown>
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const NORMAL_FLOW = "backlog → todo → in_progress → pending_review → done"
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Builds a declarative error message for transition_not_allowed errors.
|
|
14
|
+
* Includes allowed transitions, review task hint, and corrective guidance.
|
|
15
|
+
*/
|
|
16
|
+
const buildTransitionErrorMessage = (
|
|
17
|
+
from: Status,
|
|
18
|
+
to: Status,
|
|
19
|
+
taskId?: string
|
|
20
|
+
): { message: string; data: Record<string, unknown> } => {
|
|
21
|
+
const isReviewTask = taskId?.startsWith("review-") ?? false
|
|
22
|
+
const allowed = allowedTransitions[from]
|
|
23
|
+
|
|
24
|
+
let message = `Status transition not allowed: cannot move from '${from}' to '${to}'.\n\n`
|
|
25
|
+
message += `Normal flow:\n ${NORMAL_FLOW}\n\n`
|
|
26
|
+
message += `Allowed transitions from '${from}': ${allowed.join(", ") || "none"}\n\n`
|
|
27
|
+
|
|
28
|
+
message += "Special cases:\n"
|
|
29
|
+
message +=
|
|
30
|
+
" - Review tasks (id starting with 'review-') can skip pending_review: in_progress → done\n"
|
|
31
|
+
message += " - blocked tasks return to in_progress\n"
|
|
32
|
+
message += " - need_info tasks return to in_progress\n"
|
|
33
|
+
message += " - Tasks in pending_review can return to in_progress or proceed to done\n\n"
|
|
34
|
+
|
|
35
|
+
if (allowed.length === 0) {
|
|
36
|
+
message += "This status is terminal. No further transitions are possible."
|
|
37
|
+
} else {
|
|
38
|
+
message += `To proceed: transition to one of [${allowed.join(", ")}] first.`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const hint =
|
|
42
|
+
allowed.length === 0
|
|
43
|
+
? "No further transitions possible from done."
|
|
44
|
+
: `Try transitioning to ${allowed[0]} first.`
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
message,
|
|
48
|
+
data: {
|
|
49
|
+
from,
|
|
50
|
+
to,
|
|
51
|
+
taskId,
|
|
52
|
+
allowedFrom: Object.keys(allowedTransitions).filter((s) =>
|
|
53
|
+
allowedTransitions[s as Status].includes(from)
|
|
54
|
+
),
|
|
55
|
+
allowedTo: allowed,
|
|
56
|
+
normalFlow: NORMAL_FLOW,
|
|
57
|
+
isReviewTask,
|
|
58
|
+
hint,
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Builds a declarative error message for missing_comment errors.
|
|
65
|
+
*/
|
|
66
|
+
const buildMissingCommentMessage = (
|
|
67
|
+
from?: Status,
|
|
68
|
+
to?: Status
|
|
69
|
+
): { message: string; data: Record<string, unknown> } => {
|
|
70
|
+
let message = "A comment is required for this transition.\n\n"
|
|
71
|
+
|
|
72
|
+
if (from && to) {
|
|
73
|
+
message += `Transition: ${from} → ${to} requires a comment.\n`
|
|
74
|
+
if (to === "need_info") {
|
|
75
|
+
message +=
|
|
76
|
+
"Reason: need_info status requires documentation of what information is needed.\n\n"
|
|
77
|
+
} else if (to === "blocked") {
|
|
78
|
+
message += "Reason: blocked status requires documentation of why the task is blocked.\n\n"
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
message += "Include a comment with non-empty content to proceed."
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
message,
|
|
86
|
+
data: { from, to },
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Builds a declarative error message for validation_error messages.
|
|
92
|
+
* Matches known raw messages and enhances them with context.
|
|
93
|
+
*/
|
|
94
|
+
const buildValidationErrorMessage = (
|
|
95
|
+
rawMessage: string,
|
|
96
|
+
context?: Record<string, unknown>
|
|
97
|
+
): { message: string; data: Record<string, unknown> } => {
|
|
98
|
+
// Concurrent in_progress guard
|
|
99
|
+
if (rawMessage === "moving a second task to in_progress requires a justification comment") {
|
|
100
|
+
const inProgressTasks = (context?.inProgressTasks as Array<{ id: string; title: string }>) ?? []
|
|
101
|
+
let message =
|
|
102
|
+
"Cannot move this task to in_progress: another task is already in_progress for this session.\n\n"
|
|
103
|
+
|
|
104
|
+
if (inProgressTasks.length > 0) {
|
|
105
|
+
message += "Current in_progress tasks for this session:\n"
|
|
106
|
+
for (const t of inProgressTasks) {
|
|
107
|
+
message += ` - ${t.title} (id: ${t.id})\n`
|
|
108
|
+
}
|
|
109
|
+
message += "\n"
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
message +=
|
|
113
|
+
"When moving a second task to in_progress, you must provide a justification comment\n"
|
|
114
|
+
message += "explaining why this task takes priority over the existing one.\n\n"
|
|
115
|
+
message += "Include a non-empty comment explaining the priority change."
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
message,
|
|
119
|
+
data: { message: rawMessage, ...context },
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Reply on regular comment
|
|
124
|
+
if (rawMessage === "reply is only valid on need_info comments") {
|
|
125
|
+
const commentId = context?.commentId as string | undefined
|
|
126
|
+
const commentKind = context?.commentKind as string | undefined
|
|
127
|
+
|
|
128
|
+
let message = "Cannot reply to this comment.\n\n"
|
|
129
|
+
if (commentId) {
|
|
130
|
+
message += `Comment id: ${commentId}\n`
|
|
131
|
+
}
|
|
132
|
+
if (commentKind) {
|
|
133
|
+
message += `Comment kind: ${commentKind}\n`
|
|
134
|
+
}
|
|
135
|
+
message +=
|
|
136
|
+
"\nReplies are only allowed on 'need_info' comments. Regular comments cannot receive replies.\n\n"
|
|
137
|
+
message +=
|
|
138
|
+
"To proceed: either remove the reply field, or change the comment kind to 'need_info'."
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
message,
|
|
142
|
+
data: { message: rawMessage, ...context },
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Blocking comment without reply
|
|
147
|
+
if (rawMessage.startsWith("blocking comment ") && rawMessage.endsWith(" has no reply")) {
|
|
148
|
+
const commentId = context?.commentId as string | undefined
|
|
149
|
+
const commentTitle = context?.commentTitle as string | undefined
|
|
150
|
+
const commentContent = context?.commentContent as string | undefined
|
|
151
|
+
const commentTimestamp = context?.commentTimestamp as Date | undefined
|
|
152
|
+
|
|
153
|
+
let message =
|
|
154
|
+
"Cannot transition from need_info to in_progress: blocking comment has no reply.\n\n"
|
|
155
|
+
message += "Blocking comment:\n"
|
|
156
|
+
if (commentId) message += ` - id: ${commentId}\n`
|
|
157
|
+
if (commentTitle) message += ` - title: ${commentTitle}\n`
|
|
158
|
+
if (commentContent) {
|
|
159
|
+
const truncated =
|
|
160
|
+
commentContent.length > 50 ? `${commentContent.slice(0, 50)}...` : commentContent
|
|
161
|
+
message += ` - content: ${truncated}\n`
|
|
162
|
+
}
|
|
163
|
+
if (commentTimestamp) message += ` - created: ${commentTimestamp.toISOString()}\n`
|
|
164
|
+
message += "\nYou must reply to this need_info comment before transitioning to in_progress.\n\n"
|
|
165
|
+
message += "Include a reply in your comment to proceed."
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
message,
|
|
169
|
+
data: { message: rawMessage, ...context },
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Empty blocked content
|
|
174
|
+
if (rawMessage === "blocked requires a non-empty comment") {
|
|
175
|
+
let message = "Cannot transition to blocked with an empty comment.\n\n"
|
|
176
|
+
message += "When blocking a task, you must provide a reason in the comment content.\n\n"
|
|
177
|
+
message += "Include a non-empty comment explaining why the task is blocked."
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
message,
|
|
181
|
+
data: { message: rawMessage, ...context },
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Fallback: pass through unknown validation errors
|
|
186
|
+
return {
|
|
187
|
+
message: rawMessage,
|
|
188
|
+
data: { message: rawMessage, ...context },
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export const taskErrorToMcpError = (err: TaskError): McpError => {
|
|
193
|
+
switch (err._tag) {
|
|
194
|
+
case "not_found":
|
|
195
|
+
return { code: -32001, message: "Task not found", data: { taskId: err.taskId } }
|
|
196
|
+
case "transition_not_allowed": {
|
|
197
|
+
const { message, data } = buildTransitionErrorMessage(err.from, err.to, err.taskId)
|
|
198
|
+
return { code: -32002, message, data }
|
|
199
|
+
}
|
|
200
|
+
case "validation_error": {
|
|
201
|
+
const { message, data } = buildValidationErrorMessage(err.message, err.context)
|
|
202
|
+
return { code: -32003, message, data }
|
|
203
|
+
}
|
|
204
|
+
case "missing_comment": {
|
|
205
|
+
const { message, data } = buildMissingCommentMessage(err.from, err.to)
|
|
206
|
+
return { code: -32004, message, data }
|
|
207
|
+
}
|
|
208
|
+
case "conflict":
|
|
209
|
+
return { code: -32005, message: "Task already exists", data: { taskId: err.taskId } }
|
|
210
|
+
case "no_current_task":
|
|
211
|
+
return { code: -32006, message: "No current task for this session", data: {} }
|
|
212
|
+
}
|
|
213
|
+
}
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { createInterface } from "node:readline"
|
|
3
|
+
import { Effect, Layer } from "effect"
|
|
4
|
+
import { runInit } from "../cli/init.js"
|
|
5
|
+
import { executeHooks } from "../hook/hook-executor.js"
|
|
6
|
+
import type { HookEvent } from "../hook/ports.js"
|
|
7
|
+
import { HookRunner } from "../hook/ports.js"
|
|
8
|
+
import { loadHookConfigs } from "../infra/hook-config-loader.js"
|
|
9
|
+
import { JsonlTaskRepository } from "../infra/jsonl-task-repository.js"
|
|
10
|
+
import { PidSessionRegistry } from "../infra/pid-session-registry.js"
|
|
11
|
+
import { TaskRepository } from "../task/ports.js"
|
|
12
|
+
import { SessionRegistry } from "../task/session-registry.js"
|
|
13
|
+
import { taskErrorToMcpError } from "./error-codes.js"
|
|
14
|
+
import { newSessionId } from "./session.js"
|
|
15
|
+
import { toolCreateTask } from "./tool-create-task.js"
|
|
16
|
+
import { toolCurrentTask } from "./tool-current-task.js"
|
|
17
|
+
import { toolEditTask } from "./tool-edit-task.js"
|
|
18
|
+
import { toolListTasks } from "./tool-list-tasks.js"
|
|
19
|
+
import { toolUpdateTask } from "./tool-update-task.js"
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// JSON-RPC 2.0 types
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
interface JsonRpcRequest {
|
|
26
|
+
jsonrpc: "2.0"
|
|
27
|
+
id?: string | number | null
|
|
28
|
+
method: string
|
|
29
|
+
params?: unknown
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface JsonRpcSuccess {
|
|
33
|
+
jsonrpc: "2.0"
|
|
34
|
+
id: string | number | null
|
|
35
|
+
result: unknown
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface JsonRpcError {
|
|
39
|
+
jsonrpc: "2.0"
|
|
40
|
+
id: string | number | null
|
|
41
|
+
error: {
|
|
42
|
+
code: number
|
|
43
|
+
message: string
|
|
44
|
+
data?: unknown
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
type JsonRpcResponse = JsonRpcSuccess | JsonRpcError
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Response helpers (pure)
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
const successResponse = (id: string | number | null, result: unknown): JsonRpcSuccess => ({
|
|
55
|
+
jsonrpc: "2.0",
|
|
56
|
+
id,
|
|
57
|
+
result,
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
const errorResponse = (
|
|
61
|
+
id: string | number | null,
|
|
62
|
+
code: number,
|
|
63
|
+
message: string,
|
|
64
|
+
data?: unknown
|
|
65
|
+
): JsonRpcError => ({
|
|
66
|
+
jsonrpc: "2.0",
|
|
67
|
+
id,
|
|
68
|
+
error: data !== undefined ? { code, message, data } : { code, message },
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
const parseError = (id: string | number | null): JsonRpcError =>
|
|
72
|
+
errorResponse(id, -32700, "Parse error")
|
|
73
|
+
const methodNotFound = (id: string | number | null, method: string): JsonRpcError =>
|
|
74
|
+
errorResponse(id, -32601, `Method not found: ${method}`)
|
|
75
|
+
const internalError = (id: string | number | null, message: string): JsonRpcError =>
|
|
76
|
+
errorResponse(id, -32603, message)
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// Agent instructions injected into the MCP initialize response
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
const AGENT_INSTRUCTIONS = `\
|
|
83
|
+
You are connected to the logbook MCP server. You MUST use it to track all tasks in this session.
|
|
84
|
+
|
|
85
|
+
## Session start
|
|
86
|
+
Call \`current_task\` immediately. If it returns \`no_current_task\`, pick a task from \`list_tasks\` with status \`todo\` and move it to \`in_progress\`, or create a new one with \`create_task\` then advance it: backlog → todo → in_progress.
|
|
87
|
+
|
|
88
|
+
## Task lifecycle
|
|
89
|
+
backlog → todo → in_progress → pending_review → done
|
|
90
|
+
Side-exits from in_progress: → need_info (awaiting clarification) or → blocked (external dependency). Return to in_progress once resolved.
|
|
91
|
+
Always attach a comment when moving to pending_review.
|
|
92
|
+
|
|
93
|
+
## Model selection when spawning sub-agents
|
|
94
|
+
Base the choice on the \`predictedKTokens\` you set at task creation:
|
|
95
|
+
|
|
96
|
+
| predictedKTokens | model | use for |
|
|
97
|
+
|------------------|----------------------------|----------------------------------|
|
|
98
|
+
| ≤ 5 | claude-haiku-4-5-20251001 | rote / mechanical tasks |
|
|
99
|
+
| 6 – 15 | claude-sonnet-4-6 | moderate complexity |
|
|
100
|
+
| 16+ | claude-sonnet-4-6 | large but well-scoped tasks |
|
|
101
|
+
|
|
102
|
+
Override to \`claude-opus-4-6\` regardless of size when the task involves: architectural design, security analysis, creative problem-solving, or complex multi-step reasoning.`
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// MCP tools manifest (static, derived from Zod schemas in each tool file)
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
const STATUS_ENUM = [
|
|
109
|
+
"backlog",
|
|
110
|
+
"todo",
|
|
111
|
+
"need_info",
|
|
112
|
+
"blocked",
|
|
113
|
+
"in_progress",
|
|
114
|
+
"pending_review",
|
|
115
|
+
"done",
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
const TOOLS_LIST = [
|
|
119
|
+
{
|
|
120
|
+
name: "list_tasks",
|
|
121
|
+
description: "List tasks, optionally filtered by status. Defaults to in_progress.",
|
|
122
|
+
inputSchema: {
|
|
123
|
+
type: "object",
|
|
124
|
+
properties: {
|
|
125
|
+
status: {
|
|
126
|
+
oneOf: [
|
|
127
|
+
{ type: "string", enum: STATUS_ENUM },
|
|
128
|
+
{ type: "string", enum: ["*"] },
|
|
129
|
+
],
|
|
130
|
+
description: "Status filter. Use '*' for all tasks. Defaults to 'in_progress'.",
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
name: "current_task",
|
|
137
|
+
description:
|
|
138
|
+
"Return the highest-priority in_progress task for this session. Call this at session start before doing any work.",
|
|
139
|
+
inputSchema: { type: "object", properties: {} },
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
name: "create_task",
|
|
143
|
+
description:
|
|
144
|
+
"Create a new task in backlog. Set predictedKTokens to your estimated context use — this drives the Fibonacci estimation and model selection for sub-agents.",
|
|
145
|
+
inputSchema: {
|
|
146
|
+
type: "object",
|
|
147
|
+
required: [
|
|
148
|
+
"project",
|
|
149
|
+
"milestone",
|
|
150
|
+
"title",
|
|
151
|
+
"definition_of_done",
|
|
152
|
+
"description",
|
|
153
|
+
"predictedKTokens",
|
|
154
|
+
],
|
|
155
|
+
properties: {
|
|
156
|
+
project: { type: "string" },
|
|
157
|
+
milestone: { type: "string" },
|
|
158
|
+
title: { type: "string" },
|
|
159
|
+
definition_of_done: { type: "string" },
|
|
160
|
+
description: { type: "string" },
|
|
161
|
+
predictedKTokens: { type: "number" },
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
name: "update_task",
|
|
167
|
+
description:
|
|
168
|
+
"Transition a task's status. Attach a comment when moving to pending_review. Use need_info or blocked for side-exits from in_progress.",
|
|
169
|
+
inputSchema: {
|
|
170
|
+
type: "object",
|
|
171
|
+
required: ["id", "new_status"],
|
|
172
|
+
properties: {
|
|
173
|
+
id: { type: "string" },
|
|
174
|
+
new_status: { type: "string", enum: STATUS_ENUM },
|
|
175
|
+
comment: {
|
|
176
|
+
type: "object",
|
|
177
|
+
properties: {
|
|
178
|
+
id: {
|
|
179
|
+
type: "string",
|
|
180
|
+
format: "uuid",
|
|
181
|
+
description:
|
|
182
|
+
"Existing comment id — provide only when replying to a need_info comment.",
|
|
183
|
+
},
|
|
184
|
+
title: { type: "string" },
|
|
185
|
+
content: { type: "string" },
|
|
186
|
+
reply: {
|
|
187
|
+
type: "string",
|
|
188
|
+
description: "Reply text — only meaningful when id refers to a need_info comment.",
|
|
189
|
+
},
|
|
190
|
+
kind: { type: "string", enum: ["need_info", "regular"] },
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
name: "edit_task",
|
|
198
|
+
description: "Edit mutable fields of a task without changing its status.",
|
|
199
|
+
inputSchema: {
|
|
200
|
+
type: "object",
|
|
201
|
+
required: ["id"],
|
|
202
|
+
properties: {
|
|
203
|
+
id: { type: "string" },
|
|
204
|
+
title: { type: "string" },
|
|
205
|
+
description: { type: "string" },
|
|
206
|
+
definition_of_done: { type: "string" },
|
|
207
|
+
predictedKTokens: { type: "number" },
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
]
|
|
212
|
+
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
// Server bootstrap
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
export const startServer = async (): Promise<void> => {
|
|
218
|
+
const tasksFile = process.env.LOGBOOK_TASKS_FILE ?? "./tasks.jsonl"
|
|
219
|
+
const hooksDir = process.env.LOGBOOK_HOOKS_DIR ?? "./hooks"
|
|
220
|
+
|
|
221
|
+
const configs = await loadHookConfigs(hooksDir)
|
|
222
|
+
const repo = new JsonlTaskRepository(tasksFile)
|
|
223
|
+
const registry = new PidSessionRegistry(tasksFile)
|
|
224
|
+
|
|
225
|
+
const hookRunnerImpl: HookRunner = {
|
|
226
|
+
run: (event: HookEvent) => executeHooks(event, configs),
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const repoLayer: Layer.Layer<TaskRepository> = Layer.succeed(TaskRepository, repo)
|
|
230
|
+
const fullLayer: Layer.Layer<TaskRepository | HookRunner | SessionRegistry> = Layer.merge(
|
|
231
|
+
Layer.merge(repoLayer, Layer.succeed(HookRunner, hookRunnerImpl)),
|
|
232
|
+
Layer.succeed(SessionRegistry, registry)
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
const sessionId = newSessionId()
|
|
236
|
+
await Effect.runPromise(registry.register(sessionId, process.pid))
|
|
237
|
+
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
// Tool dispatch
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
const dispatch = async (method: string, params: unknown): Promise<unknown> => {
|
|
243
|
+
switch (method) {
|
|
244
|
+
case "initialize":
|
|
245
|
+
return {
|
|
246
|
+
protocolVersion: "2024-11-05",
|
|
247
|
+
capabilities: { tools: {} },
|
|
248
|
+
serverInfo: { name: "logbook", version: "1.0.0" },
|
|
249
|
+
instructions: AGENT_INSTRUCTIONS,
|
|
250
|
+
}
|
|
251
|
+
case "tools/list":
|
|
252
|
+
return { tools: TOOLS_LIST }
|
|
253
|
+
case "tools/call": {
|
|
254
|
+
const p = params as { name: string; arguments?: unknown }
|
|
255
|
+
const result = await dispatch(p.name, p.arguments ?? {})
|
|
256
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] }
|
|
257
|
+
}
|
|
258
|
+
case "list_tasks":
|
|
259
|
+
return toolListTasks(params, repoLayer)
|
|
260
|
+
case "current_task":
|
|
261
|
+
return toolCurrentTask(sessionId, fullLayer)
|
|
262
|
+
case "update_task":
|
|
263
|
+
return toolUpdateTask(params, sessionId, fullLayer)
|
|
264
|
+
case "create_task":
|
|
265
|
+
return toolCreateTask(params, sessionId, repoLayer)
|
|
266
|
+
case "edit_task":
|
|
267
|
+
return toolEditTask(params, repoLayer)
|
|
268
|
+
default:
|
|
269
|
+
return Promise.reject(new MethodNotFoundError(method))
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
// stdio JSON-RPC loop
|
|
275
|
+
// ---------------------------------------------------------------------------
|
|
276
|
+
|
|
277
|
+
const rl = createInterface({ input: process.stdin, terminal: false })
|
|
278
|
+
|
|
279
|
+
const send = (response: JsonRpcResponse): void => {
|
|
280
|
+
process.stdout.write(`${JSON.stringify(response)}\n`)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
rl.on("line", (line) => {
|
|
284
|
+
const trimmed = line.trim()
|
|
285
|
+
if (trimmed === "") return
|
|
286
|
+
|
|
287
|
+
let request: JsonRpcRequest
|
|
288
|
+
try {
|
|
289
|
+
request = JSON.parse(trimmed) as JsonRpcRequest
|
|
290
|
+
} catch {
|
|
291
|
+
send(parseError(null))
|
|
292
|
+
return
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// MCP notifications have no `id` field — do not send a response
|
|
296
|
+
if (!("id" in request)) {
|
|
297
|
+
void dispatch(request.method, request.params ?? {}).catch(() => {})
|
|
298
|
+
return
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const id = request.id ?? null
|
|
302
|
+
|
|
303
|
+
dispatch(request.method, request.params ?? {})
|
|
304
|
+
.then((result) => {
|
|
305
|
+
send(successResponse(id, result))
|
|
306
|
+
})
|
|
307
|
+
.catch((err: unknown) => {
|
|
308
|
+
if (err instanceof MethodNotFoundError) {
|
|
309
|
+
send(methodNotFound(id, err.method))
|
|
310
|
+
return
|
|
311
|
+
}
|
|
312
|
+
// Task domain errors come through Effect.runPromise rejections
|
|
313
|
+
if (isTaskError(err)) {
|
|
314
|
+
const mcpErr = taskErrorToMcpError(err)
|
|
315
|
+
send(errorResponse(id, mcpErr.code, mcpErr.message, mcpErr.data))
|
|
316
|
+
return
|
|
317
|
+
}
|
|
318
|
+
// Zod parse errors from tool input validation
|
|
319
|
+
if (isZodError(err)) {
|
|
320
|
+
send(errorResponse(id, -32602, "Invalid params", { issues: err.errors }))
|
|
321
|
+
return
|
|
322
|
+
}
|
|
323
|
+
send(internalError(id, String(err)))
|
|
324
|
+
})
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
rl.on("close", () => {
|
|
328
|
+
void Effect.runPromise(registry.deregister(sessionId)).then(() => process.exit(0))
|
|
329
|
+
})
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ---------------------------------------------------------------------------
|
|
333
|
+
// Internal error sentinel
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
|
|
336
|
+
class MethodNotFoundError extends Error {
|
|
337
|
+
constructor(readonly method: string) {
|
|
338
|
+
super(`Method not found: ${method}`)
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// ---------------------------------------------------------------------------
|
|
343
|
+
// Type narrowing helpers (pure)
|
|
344
|
+
// ---------------------------------------------------------------------------
|
|
345
|
+
|
|
346
|
+
const isTaskError = (e: unknown): e is import("../domain/types.js").TaskError =>
|
|
347
|
+
typeof e === "object" &&
|
|
348
|
+
e !== null &&
|
|
349
|
+
typeof (e as { _tag?: unknown })._tag === "string" &&
|
|
350
|
+
[
|
|
351
|
+
"not_found",
|
|
352
|
+
"transition_not_allowed",
|
|
353
|
+
"validation_error",
|
|
354
|
+
"missing_comment",
|
|
355
|
+
"conflict",
|
|
356
|
+
"no_current_task",
|
|
357
|
+
].includes((e as { _tag: string })._tag)
|
|
358
|
+
|
|
359
|
+
interface ZodError {
|
|
360
|
+
errors: unknown[]
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const isZodError = (e: unknown): e is ZodError =>
|
|
364
|
+
typeof e === "object" &&
|
|
365
|
+
e !== null &&
|
|
366
|
+
Array.isArray((e as { errors?: unknown }).errors) &&
|
|
367
|
+
"name" in (e as object) &&
|
|
368
|
+
(e as { name: string }).name === "ZodError"
|
|
369
|
+
|
|
370
|
+
// ---------------------------------------------------------------------------
|
|
371
|
+
// CLI flag handling (before server startup)
|
|
372
|
+
// ---------------------------------------------------------------------------
|
|
373
|
+
|
|
374
|
+
const handleCliFlags = async (): Promise<void> => {
|
|
375
|
+
const arg = process.argv[2]
|
|
376
|
+
|
|
377
|
+
if (arg === "init") {
|
|
378
|
+
await runInit()
|
|
379
|
+
process.exit(0)
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (arg === "--version" || arg === "-v") {
|
|
383
|
+
const pkg = await import("../../package.json", { with: { type: "json" } })
|
|
384
|
+
process.stdout.write(`${pkg.default.version}\n`)
|
|
385
|
+
process.exit(0)
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (arg === "--help" || arg === "-h") {
|
|
389
|
+
process.stdout.write(`logbook-mcp [command]
|
|
390
|
+
|
|
391
|
+
Commands:
|
|
392
|
+
init Scaffold tasks.jsonl, hooks/, and emit client config snippets
|
|
393
|
+
(default) Start the MCP server (stdio transport)
|
|
394
|
+
|
|
395
|
+
Options:
|
|
396
|
+
--version Print version
|
|
397
|
+
--help Show this help
|
|
398
|
+
|
|
399
|
+
Environment:
|
|
400
|
+
LOGBOOK_TASKS_FILE Path to JSONL task store (default: ./tasks.jsonl)
|
|
401
|
+
LOGBOOK_HOOKS_DIR Directory for hook definitions (default: ./hooks)
|
|
402
|
+
LOGBOOK_LOG_LEVEL Log level: debug|info|warn|error (default: warn)
|
|
403
|
+
`)
|
|
404
|
+
process.exit(0)
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// ---------------------------------------------------------------------------
|
|
409
|
+
// Entry point
|
|
410
|
+
// ---------------------------------------------------------------------------
|
|
411
|
+
|
|
412
|
+
await handleCliFlags()
|
|
413
|
+
await startServer()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const newSessionId = (): string => crypto.randomUUID()
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Effect, Either, type Layer } from "effect"
|
|
2
|
+
import { z } from "zod"
|
|
3
|
+
import { createTask } from "../task/create-task.js"
|
|
4
|
+
import type { TaskRepository } from "../task/ports.js"
|
|
5
|
+
|
|
6
|
+
const InputSchema = z.object({
|
|
7
|
+
project: z.string().min(1),
|
|
8
|
+
milestone: z.string().min(1),
|
|
9
|
+
title: z.string().min(1),
|
|
10
|
+
definition_of_done: z.string().min(1),
|
|
11
|
+
description: z.string().min(1),
|
|
12
|
+
predictedKTokens: z.number().positive(),
|
|
13
|
+
priority: z.number().int().min(0).default(0),
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
export const toolCreateTask = (
|
|
17
|
+
rawInput: unknown,
|
|
18
|
+
_sessionId: string,
|
|
19
|
+
layer: Layer.Layer<TaskRepository>
|
|
20
|
+
): Promise<{ task: unknown }> => {
|
|
21
|
+
const input = InputSchema.parse(rawInput)
|
|
22
|
+
return Effect.runPromise(
|
|
23
|
+
Effect.provide(Effect.either(createTask(input).pipe(Effect.map((task) => ({ task })))), layer)
|
|
24
|
+
).then((either) => {
|
|
25
|
+
if (Either.isLeft(either)) throw either.left
|
|
26
|
+
return either.right
|
|
27
|
+
})
|
|
28
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Effect, Either, type Layer } from "effect"
|
|
2
|
+
import { currentTask } from "../task/current-task.js"
|
|
3
|
+
import type { TaskRepository } from "../task/ports.js"
|
|
4
|
+
import type { SessionRegistry } from "../task/session-registry.js"
|
|
5
|
+
|
|
6
|
+
export const toolCurrentTask = (
|
|
7
|
+
sessionId: string,
|
|
8
|
+
layer: Layer.Layer<TaskRepository | SessionRegistry>
|
|
9
|
+
): Promise<{ task: unknown }> => {
|
|
10
|
+
return Effect.runPromise(
|
|
11
|
+
Effect.provide(
|
|
12
|
+
Effect.either(currentTask(sessionId).pipe(Effect.map((task) => ({ task })))),
|
|
13
|
+
layer
|
|
14
|
+
)
|
|
15
|
+
).then((either) => {
|
|
16
|
+
if (Either.isLeft(either)) throw either.left
|
|
17
|
+
return either.right
|
|
18
|
+
})
|
|
19
|
+
}
|