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,99 @@
|
|
|
1
|
+
import { readdir, readFile } from "node:fs/promises"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
import { z } from "zod"
|
|
4
|
+
import type { HookConfig } from "../hook/hook-executor.js"
|
|
5
|
+
|
|
6
|
+
const HookConfigFileSchema = z.object({
|
|
7
|
+
event: z.string(),
|
|
8
|
+
condition: z.string().optional(),
|
|
9
|
+
timeout_ms: z.number().optional(),
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Parses a strict subset of YAML: flat key-value pairs, no nesting.
|
|
14
|
+
* Supports quoted strings and bare integers.
|
|
15
|
+
*/
|
|
16
|
+
const parseSimpleYaml = (content: string): Record<string, unknown> => {
|
|
17
|
+
const result: Record<string, unknown> = {}
|
|
18
|
+
for (const line of content.split("\n")) {
|
|
19
|
+
const trimmed = line.trim()
|
|
20
|
+
if (!trimmed || trimmed.startsWith("#")) continue
|
|
21
|
+
const colonIdx = trimmed.indexOf(":")
|
|
22
|
+
if (colonIdx === -1) continue
|
|
23
|
+
const key = trimmed.slice(0, colonIdx).trim()
|
|
24
|
+
let value: unknown = trimmed.slice(colonIdx + 1).trim()
|
|
25
|
+
if (typeof value === "string" && value.startsWith('"') && value.endsWith('"')) {
|
|
26
|
+
value = value.slice(1, -1)
|
|
27
|
+
} else if (typeof value === "string" && value.startsWith("'") && value.endsWith("'")) {
|
|
28
|
+
value = value.slice(1, -1)
|
|
29
|
+
} else if (typeof value === "string" && /^\d+$/.test(value)) {
|
|
30
|
+
value = parseInt(value, 10)
|
|
31
|
+
}
|
|
32
|
+
result[key] = value
|
|
33
|
+
}
|
|
34
|
+
return result
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const SCRIPT_CANDIDATES = ["script.ts", "script.sh"] as const
|
|
38
|
+
|
|
39
|
+
const findScript = async (hookDir: string): Promise<string | undefined> => {
|
|
40
|
+
for (const name of SCRIPT_CANDIDATES) {
|
|
41
|
+
const candidate = join(hookDir, name)
|
|
42
|
+
try {
|
|
43
|
+
await readFile(candidate)
|
|
44
|
+
return candidate
|
|
45
|
+
} catch {
|
|
46
|
+
// try next candidate
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return undefined
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const loadHookConfigs = async (hooksDir: string): Promise<HookConfig[]> => {
|
|
53
|
+
let entries: string[]
|
|
54
|
+
try {
|
|
55
|
+
entries = await readdir(hooksDir)
|
|
56
|
+
} catch (e: unknown) {
|
|
57
|
+
if (isEnoent(e)) return []
|
|
58
|
+
throw e
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const configs: HookConfig[] = []
|
|
62
|
+
|
|
63
|
+
for (const entry of entries) {
|
|
64
|
+
const hookDir = join(hooksDir, entry)
|
|
65
|
+
try {
|
|
66
|
+
const configPath = join(hookDir, "config.yml")
|
|
67
|
+
const raw = await readFile(configPath, "utf8")
|
|
68
|
+
const parsed = parseSimpleYaml(raw)
|
|
69
|
+
const validated = HookConfigFileSchema.safeParse(parsed)
|
|
70
|
+
if (!validated.success) {
|
|
71
|
+
console.warn(
|
|
72
|
+
`[hook-config-loader] invalid config at ${configPath}:`,
|
|
73
|
+
validated.error.message
|
|
74
|
+
)
|
|
75
|
+
continue
|
|
76
|
+
}
|
|
77
|
+
const script = await findScript(hookDir)
|
|
78
|
+
if (script === undefined) {
|
|
79
|
+
console.warn(`[hook-config-loader] no script found in ${hookDir}, skipping`)
|
|
80
|
+
continue
|
|
81
|
+
}
|
|
82
|
+
const { event, condition, timeout_ms } = validated.data
|
|
83
|
+
const config: HookConfig = {
|
|
84
|
+
event,
|
|
85
|
+
script,
|
|
86
|
+
...(condition !== undefined ? { condition } : {}),
|
|
87
|
+
...(timeout_ms !== undefined ? { timeout_ms } : {}),
|
|
88
|
+
}
|
|
89
|
+
configs.push(config)
|
|
90
|
+
} catch (e: unknown) {
|
|
91
|
+
console.warn(`[hook-config-loader] failed to load hook "${entry}":`, String(e))
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return configs
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const isEnoent = (e: unknown): boolean =>
|
|
99
|
+
typeof e === "object" && e !== null && (e as { code?: unknown }).code === "ENOENT"
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { appendFile, readFile, rename, writeFile } from "node:fs/promises"
|
|
2
|
+
import { Effect } from "effect"
|
|
3
|
+
import type { Status, Task, TaskError } from "../domain/types.js"
|
|
4
|
+
import { TaskSchema } from "../domain/types.js"
|
|
5
|
+
import type { TaskRepository } from "../task/ports.js"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* JSONL-backed TaskRepository.
|
|
9
|
+
* Each line is a JSON-serialized Task.
|
|
10
|
+
* Reads scan the full file; writes are append-only for save, full-rewrite for update.
|
|
11
|
+
*/
|
|
12
|
+
export class JsonlTaskRepository implements TaskRepository {
|
|
13
|
+
constructor(private readonly filePath: string) {}
|
|
14
|
+
|
|
15
|
+
save(task: Task): Effect.Effect<void, TaskError> {
|
|
16
|
+
return Effect.tryPromise<void, TaskError>({
|
|
17
|
+
try: async () => {
|
|
18
|
+
const content = await readFile(this.filePath, "utf8").catch((e: unknown) => {
|
|
19
|
+
if (isEnoent(e)) return ""
|
|
20
|
+
throw e
|
|
21
|
+
})
|
|
22
|
+
const lines = splitLines(content)
|
|
23
|
+
const conflict = lines.some((line) => {
|
|
24
|
+
try {
|
|
25
|
+
const parsed = JSON.parse(line) as unknown
|
|
26
|
+
return (parsed as { id?: unknown }).id === task.id
|
|
27
|
+
} catch {
|
|
28
|
+
return false
|
|
29
|
+
}
|
|
30
|
+
})
|
|
31
|
+
if (conflict) {
|
|
32
|
+
throw mkTagged<TaskError>({ _tag: "conflict", taskId: task.id })
|
|
33
|
+
}
|
|
34
|
+
await appendFile(this.filePath, `${JSON.stringify(task)}\n`, "utf8")
|
|
35
|
+
},
|
|
36
|
+
catch: (e) => asTaskError(e, task.id),
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
update(task: Task): Effect.Effect<void, TaskError> {
|
|
41
|
+
return Effect.tryPromise<void, TaskError>({
|
|
42
|
+
try: async () => {
|
|
43
|
+
const content = await readFile(this.filePath, "utf8").catch((e: unknown) => {
|
|
44
|
+
if (isEnoent(e)) return ""
|
|
45
|
+
throw e
|
|
46
|
+
})
|
|
47
|
+
const lines = splitLines(content)
|
|
48
|
+
let found = false
|
|
49
|
+
const updated = lines.map((line) => {
|
|
50
|
+
try {
|
|
51
|
+
const parsed = JSON.parse(line) as unknown
|
|
52
|
+
if ((parsed as { id?: unknown }).id === task.id) {
|
|
53
|
+
found = true
|
|
54
|
+
return JSON.stringify(task)
|
|
55
|
+
}
|
|
56
|
+
} catch {
|
|
57
|
+
// keep malformed lines as-is
|
|
58
|
+
}
|
|
59
|
+
return line
|
|
60
|
+
})
|
|
61
|
+
if (!found) {
|
|
62
|
+
throw mkTagged<TaskError>({ _tag: "not_found", taskId: task.id })
|
|
63
|
+
}
|
|
64
|
+
const tmpPath = `${this.filePath}.tmp`
|
|
65
|
+
await writeFile(tmpPath, `${updated.join("\n")}\n`, "utf8")
|
|
66
|
+
await rename(tmpPath, this.filePath)
|
|
67
|
+
},
|
|
68
|
+
catch: (e) => asTaskError(e, task.id),
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
findById(id: string): Effect.Effect<Task, TaskError> {
|
|
73
|
+
return Effect.tryPromise<Task, TaskError>({
|
|
74
|
+
try: async () => {
|
|
75
|
+
const content = await readFile(this.filePath, "utf8").catch((e: unknown) => {
|
|
76
|
+
if (isEnoent(e)) return ""
|
|
77
|
+
throw e
|
|
78
|
+
})
|
|
79
|
+
const lines = splitLines(content)
|
|
80
|
+
for (const line of lines) {
|
|
81
|
+
const result = parseLine(line)
|
|
82
|
+
if (result._tag === "error") {
|
|
83
|
+
console.warn("[JsonlTaskRepository] skipping malformed line:", result.reason)
|
|
84
|
+
continue
|
|
85
|
+
}
|
|
86
|
+
if (result.task.id === id) return result.task
|
|
87
|
+
}
|
|
88
|
+
throw mkTagged<TaskError>({ _tag: "not_found", taskId: id })
|
|
89
|
+
},
|
|
90
|
+
catch: (e) => asTaskError(e, id),
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
findByStatus(status: Status | "*"): Effect.Effect<readonly Task[], TaskError> {
|
|
95
|
+
return Effect.tryPromise<readonly Task[], TaskError>({
|
|
96
|
+
try: async () => {
|
|
97
|
+
const content = await readFile(this.filePath, "utf8").catch((e: unknown) => {
|
|
98
|
+
if (isEnoent(e)) return ""
|
|
99
|
+
throw e
|
|
100
|
+
})
|
|
101
|
+
const lines = splitLines(content)
|
|
102
|
+
const tasks: Task[] = []
|
|
103
|
+
for (const line of lines) {
|
|
104
|
+
const result = parseLine(line)
|
|
105
|
+
if (result._tag === "error") {
|
|
106
|
+
throw mkTagged<TaskError>({ _tag: "validation_error", message: result.reason })
|
|
107
|
+
}
|
|
108
|
+
if (status === "*" || result.task.status === status) {
|
|
109
|
+
tasks.push(result.task)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return tasks
|
|
113
|
+
},
|
|
114
|
+
catch: (e) => asTaskError(e, ""),
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// Helpers (pure)
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
type ParseResult =
|
|
124
|
+
| { readonly _tag: "ok"; readonly task: Task }
|
|
125
|
+
| { readonly _tag: "error"; readonly reason: string }
|
|
126
|
+
|
|
127
|
+
const parseLine = (line: string): ParseResult => {
|
|
128
|
+
let raw: unknown
|
|
129
|
+
try {
|
|
130
|
+
raw = JSON.parse(line)
|
|
131
|
+
} catch (e) {
|
|
132
|
+
return { _tag: "error", reason: `invalid JSON: ${String(e)}` }
|
|
133
|
+
}
|
|
134
|
+
const result = TaskSchema.safeParse(raw)
|
|
135
|
+
if (!result.success) {
|
|
136
|
+
return { _tag: "error", reason: result.error.message }
|
|
137
|
+
}
|
|
138
|
+
return { _tag: "ok", task: result.data }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const splitLines = (content: string): readonly string[] =>
|
|
142
|
+
content.split("\n").filter((l) => l.trim() !== "")
|
|
143
|
+
|
|
144
|
+
const isEnoent = (e: unknown): boolean =>
|
|
145
|
+
typeof e === "object" && e !== null && (e as { code?: unknown }).code === "ENOENT"
|
|
146
|
+
|
|
147
|
+
/** Carries a typed TaskError through the tryPromise boundary. */
|
|
148
|
+
const mkTagged = <E>(value: E): E => value
|
|
149
|
+
|
|
150
|
+
const asTaskError = (e: unknown, _taskId: string): TaskError => {
|
|
151
|
+
if (isTaskError(e)) return e
|
|
152
|
+
return { _tag: "validation_error", message: String(e) } satisfies TaskError
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const isTaskError = (e: unknown): e is TaskError =>
|
|
156
|
+
typeof e === "object" && e !== null && typeof (e as { _tag?: unknown })._tag === "string"
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { TaskError } from "../domain/types.js"
|
|
2
|
+
|
|
3
|
+
export interface McpError {
|
|
4
|
+
code: number
|
|
5
|
+
message: string
|
|
6
|
+
data: Record<string, unknown>
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const taskErrorToMcpError = (err: TaskError): McpError => {
|
|
10
|
+
switch (err._tag) {
|
|
11
|
+
case "not_found":
|
|
12
|
+
return { code: -32001, message: "Task not found", data: { taskId: err.taskId } }
|
|
13
|
+
case "transition_not_allowed":
|
|
14
|
+
return {
|
|
15
|
+
code: -32002,
|
|
16
|
+
message: "Status transition not allowed",
|
|
17
|
+
data: { from: err.from, to: err.to },
|
|
18
|
+
}
|
|
19
|
+
case "validation_error":
|
|
20
|
+
return { code: -32003, message: "Validation error", data: { message: err.message } }
|
|
21
|
+
case "missing_comment":
|
|
22
|
+
return { code: -32004, message: "A comment is required for this transition", data: {} }
|
|
23
|
+
case "conflict":
|
|
24
|
+
return { code: -32005, message: "Task already exists", data: { taskId: err.taskId } }
|
|
25
|
+
case "no_current_task":
|
|
26
|
+
return { code: -32006, message: "No current task for this session", data: {} }
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { createInterface } from "node:readline"
|
|
3
|
+
import { Layer } from "effect"
|
|
4
|
+
import { executeHooks } from "../hook/hook-executor.js"
|
|
5
|
+
import type { HookEvent } from "../hook/ports.js"
|
|
6
|
+
import { HookRunner } from "../hook/ports.js"
|
|
7
|
+
import { loadHookConfigs } from "../infra/hook-config-loader.js"
|
|
8
|
+
import { JsonlTaskRepository } from "../infra/jsonl-task-repository.js"
|
|
9
|
+
import { TaskRepository } from "../task/ports.js"
|
|
10
|
+
import { taskErrorToMcpError } from "./error-codes.js"
|
|
11
|
+
import { newSessionId } from "./session.js"
|
|
12
|
+
import { toolCreateTask } from "./tool-create-task.js"
|
|
13
|
+
import { toolCurrentTask } from "./tool-current-task.js"
|
|
14
|
+
import { toolEditTask } from "./tool-edit-task.js"
|
|
15
|
+
import { toolListTasks } from "./tool-list-tasks.js"
|
|
16
|
+
import { toolUpdateTask } from "./tool-update-task.js"
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// JSON-RPC 2.0 types
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
interface JsonRpcRequest {
|
|
23
|
+
jsonrpc: "2.0"
|
|
24
|
+
id: string | number | null
|
|
25
|
+
method: string
|
|
26
|
+
params?: unknown
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface JsonRpcSuccess {
|
|
30
|
+
jsonrpc: "2.0"
|
|
31
|
+
id: string | number | null
|
|
32
|
+
result: unknown
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface JsonRpcError {
|
|
36
|
+
jsonrpc: "2.0"
|
|
37
|
+
id: string | number | null
|
|
38
|
+
error: {
|
|
39
|
+
code: number
|
|
40
|
+
message: string
|
|
41
|
+
data?: unknown
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type JsonRpcResponse = JsonRpcSuccess | JsonRpcError
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Response helpers (pure)
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
const successResponse = (id: string | number | null, result: unknown): JsonRpcSuccess => ({
|
|
52
|
+
jsonrpc: "2.0",
|
|
53
|
+
id,
|
|
54
|
+
result,
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
const errorResponse = (
|
|
58
|
+
id: string | number | null,
|
|
59
|
+
code: number,
|
|
60
|
+
message: string,
|
|
61
|
+
data?: unknown
|
|
62
|
+
): JsonRpcError => ({
|
|
63
|
+
jsonrpc: "2.0",
|
|
64
|
+
id,
|
|
65
|
+
error: data !== undefined ? { code, message, data } : { code, message },
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
const parseError = (id: string | number | null): JsonRpcError =>
|
|
69
|
+
errorResponse(id, -32700, "Parse error")
|
|
70
|
+
const methodNotFound = (id: string | number | null, method: string): JsonRpcError =>
|
|
71
|
+
errorResponse(id, -32601, `Method not found: ${method}`)
|
|
72
|
+
const internalError = (id: string | number | null, message: string): JsonRpcError =>
|
|
73
|
+
errorResponse(id, -32603, message)
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// Server bootstrap
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
export const startServer = async (): Promise<void> => {
|
|
80
|
+
const tasksFile = process.env.LOGBOOK_TASKS_FILE ?? "./tasks.jsonl"
|
|
81
|
+
const hooksDir = process.env.LOGBOOK_HOOKS_DIR ?? "./hooks"
|
|
82
|
+
|
|
83
|
+
const configs = await loadHookConfigs(hooksDir)
|
|
84
|
+
const repo = new JsonlTaskRepository(tasksFile)
|
|
85
|
+
|
|
86
|
+
const hookRunnerImpl: HookRunner = {
|
|
87
|
+
run: (event: HookEvent) => executeHooks(event, configs),
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const repoLayer: Layer.Layer<TaskRepository> = Layer.succeed(TaskRepository, repo)
|
|
91
|
+
const fullLayer: Layer.Layer<TaskRepository | HookRunner> = Layer.merge(
|
|
92
|
+
repoLayer,
|
|
93
|
+
Layer.succeed(HookRunner, hookRunnerImpl)
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
const sessionId = newSessionId()
|
|
97
|
+
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// Tool dispatch
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
const dispatch = async (method: string, params: unknown): Promise<unknown> => {
|
|
103
|
+
switch (method) {
|
|
104
|
+
case "list_tasks":
|
|
105
|
+
return toolListTasks(params, repoLayer)
|
|
106
|
+
case "current_task":
|
|
107
|
+
return toolCurrentTask(sessionId, repoLayer)
|
|
108
|
+
case "update_task":
|
|
109
|
+
return toolUpdateTask(params, sessionId, fullLayer)
|
|
110
|
+
case "create_task":
|
|
111
|
+
return toolCreateTask(params, sessionId, repoLayer)
|
|
112
|
+
case "edit_task":
|
|
113
|
+
return toolEditTask(params, repoLayer)
|
|
114
|
+
default:
|
|
115
|
+
return Promise.reject(new MethodNotFoundError(method))
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// stdio JSON-RPC loop
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
const rl = createInterface({ input: process.stdin, terminal: false })
|
|
124
|
+
|
|
125
|
+
const send = (response: JsonRpcResponse): void => {
|
|
126
|
+
process.stdout.write(`${JSON.stringify(response)}\n`)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
rl.on("line", (line) => {
|
|
130
|
+
const trimmed = line.trim()
|
|
131
|
+
if (trimmed === "") return
|
|
132
|
+
|
|
133
|
+
let request: JsonRpcRequest
|
|
134
|
+
try {
|
|
135
|
+
request = JSON.parse(trimmed) as JsonRpcRequest
|
|
136
|
+
} catch {
|
|
137
|
+
send(parseError(null))
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const id = request.id ?? null
|
|
142
|
+
|
|
143
|
+
dispatch(request.method, request.params ?? {})
|
|
144
|
+
.then((result) => {
|
|
145
|
+
send(successResponse(id, result))
|
|
146
|
+
})
|
|
147
|
+
.catch((err: unknown) => {
|
|
148
|
+
if (err instanceof MethodNotFoundError) {
|
|
149
|
+
send(methodNotFound(id, err.method))
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
// Task domain errors come through Effect.runPromise rejections
|
|
153
|
+
if (isTaskError(err)) {
|
|
154
|
+
const mcpErr = taskErrorToMcpError(err)
|
|
155
|
+
send(errorResponse(id, mcpErr.code, mcpErr.message, mcpErr.data))
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
// Zod parse errors from tool input validation
|
|
159
|
+
if (isZodError(err)) {
|
|
160
|
+
send(errorResponse(id, -32602, "Invalid params", { issues: err.errors }))
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
send(internalError(id, String(err)))
|
|
164
|
+
})
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
rl.on("close", () => {
|
|
168
|
+
process.exit(0)
|
|
169
|
+
})
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
// Internal error sentinel
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
class MethodNotFoundError extends Error {
|
|
177
|
+
constructor(readonly method: string) {
|
|
178
|
+
super(`Method not found: ${method}`)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
// Type narrowing helpers (pure)
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
const isTaskError = (e: unknown): e is import("../domain/types.js").TaskError =>
|
|
187
|
+
typeof e === "object" &&
|
|
188
|
+
e !== null &&
|
|
189
|
+
typeof (e as { _tag?: unknown })._tag === "string" &&
|
|
190
|
+
[
|
|
191
|
+
"not_found",
|
|
192
|
+
"transition_not_allowed",
|
|
193
|
+
"validation_error",
|
|
194
|
+
"missing_comment",
|
|
195
|
+
"conflict",
|
|
196
|
+
"no_current_task",
|
|
197
|
+
].includes((e as { _tag: string })._tag)
|
|
198
|
+
|
|
199
|
+
interface ZodError {
|
|
200
|
+
errors: unknown[]
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const isZodError = (e: unknown): e is ZodError =>
|
|
204
|
+
typeof e === "object" &&
|
|
205
|
+
e !== null &&
|
|
206
|
+
Array.isArray((e as { errors?: unknown }).errors) &&
|
|
207
|
+
"name" in (e as object) &&
|
|
208
|
+
(e as { name: string }).name === "ZodError"
|
|
209
|
+
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
// Entry point
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
await startServer()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const newSessionId = (): string => crypto.randomUUID()
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Effect, 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
|
+
})
|
|
14
|
+
|
|
15
|
+
export const toolCreateTask = (
|
|
16
|
+
rawInput: unknown,
|
|
17
|
+
sessionId: string,
|
|
18
|
+
layer: Layer.Layer<TaskRepository>
|
|
19
|
+
): Promise<{ task: unknown }> => {
|
|
20
|
+
const input = InputSchema.parse(rawInput)
|
|
21
|
+
return Effect.runPromise(
|
|
22
|
+
Effect.provide(
|
|
23
|
+
createTask(input, sessionId).pipe(Effect.map((task) => ({ task }))),
|
|
24
|
+
layer
|
|
25
|
+
) as Effect.Effect<{ task: unknown }, never>
|
|
26
|
+
)
|
|
27
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Effect, type Layer } from "effect"
|
|
2
|
+
import { currentTask } from "../task/current-task.js"
|
|
3
|
+
import type { TaskRepository } from "../task/ports.js"
|
|
4
|
+
|
|
5
|
+
export const toolCurrentTask = (
|
|
6
|
+
sessionId: string,
|
|
7
|
+
layer: Layer.Layer<TaskRepository>
|
|
8
|
+
): Promise<{ task: unknown }> => {
|
|
9
|
+
return Effect.runPromise(
|
|
10
|
+
Effect.provide(
|
|
11
|
+
currentTask(sessionId).pipe(Effect.map((task) => ({ task }))),
|
|
12
|
+
layer
|
|
13
|
+
) as Effect.Effect<{ task: unknown }, never>
|
|
14
|
+
)
|
|
15
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Effect, type Layer } from "effect"
|
|
2
|
+
import { z } from "zod"
|
|
3
|
+
import type { EditTaskInput } from "../task/edit-task.js"
|
|
4
|
+
import { editTask } from "../task/edit-task.js"
|
|
5
|
+
import type { TaskRepository } from "../task/ports.js"
|
|
6
|
+
|
|
7
|
+
const InputSchema = z.object({
|
|
8
|
+
id: z.string().min(1),
|
|
9
|
+
title: z.string().optional(),
|
|
10
|
+
description: z.string().optional(),
|
|
11
|
+
definition_of_done: z.string().optional(),
|
|
12
|
+
predictedKTokens: z.number().positive().optional(),
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
export const toolEditTask = (
|
|
16
|
+
rawInput: unknown,
|
|
17
|
+
layer: Layer.Layer<TaskRepository>
|
|
18
|
+
): Promise<{ task: unknown }> => {
|
|
19
|
+
const parsed = InputSchema.parse(rawInput)
|
|
20
|
+
const { id } = parsed
|
|
21
|
+
// Build updates by omitting undefined fields (exact optional property types compliance)
|
|
22
|
+
const updates: EditTaskInput = {}
|
|
23
|
+
if (parsed.title !== undefined) updates.title = parsed.title
|
|
24
|
+
if (parsed.description !== undefined) updates.description = parsed.description
|
|
25
|
+
if (parsed.definition_of_done !== undefined)
|
|
26
|
+
updates.definition_of_done = parsed.definition_of_done
|
|
27
|
+
if (parsed.predictedKTokens !== undefined) updates.predictedKTokens = parsed.predictedKTokens
|
|
28
|
+
|
|
29
|
+
return Effect.runPromise(
|
|
30
|
+
Effect.provide(
|
|
31
|
+
editTask(id, updates).pipe(Effect.map((task) => ({ task }))),
|
|
32
|
+
layer
|
|
33
|
+
) as Effect.Effect<{ task: unknown }, never>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Effect, type Layer } from "effect"
|
|
2
|
+
import { z } from "zod"
|
|
3
|
+
import { StatusSchema } from "../domain/types.js"
|
|
4
|
+
import { listTasks } from "../task/list-tasks.js"
|
|
5
|
+
import type { TaskRepository } from "../task/ports.js"
|
|
6
|
+
|
|
7
|
+
const InputSchema = z.object({
|
|
8
|
+
status: z.union([StatusSchema, z.literal("*")]).default("in_progress"),
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
export const toolListTasks = (
|
|
12
|
+
rawInput: unknown,
|
|
13
|
+
layer: Layer.Layer<TaskRepository>
|
|
14
|
+
): Promise<{ tasks: unknown[] }> => {
|
|
15
|
+
const input = InputSchema.parse(rawInput)
|
|
16
|
+
return Effect.runPromise(
|
|
17
|
+
Effect.provide(
|
|
18
|
+
listTasks(input.status).pipe(Effect.map((tasks) => ({ tasks: tasks as unknown[] }))),
|
|
19
|
+
layer
|
|
20
|
+
) as Effect.Effect<{ tasks: unknown[] }, never>
|
|
21
|
+
)
|
|
22
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Effect, type Layer } from "effect"
|
|
2
|
+
import { z } from "zod"
|
|
3
|
+
import { CommentKindSchema, StatusSchema } from "../domain/types.js"
|
|
4
|
+
import type { HookRunner } from "../hook/ports.js"
|
|
5
|
+
import type { TaskRepository } from "../task/ports.js"
|
|
6
|
+
import { updateTask } from "../task/update-task.js"
|
|
7
|
+
|
|
8
|
+
const CommentInputSchema = z
|
|
9
|
+
.object({
|
|
10
|
+
title: z.string().min(1),
|
|
11
|
+
content: z.string(),
|
|
12
|
+
kind: CommentKindSchema,
|
|
13
|
+
})
|
|
14
|
+
.optional()
|
|
15
|
+
|
|
16
|
+
const InputSchema = z.object({
|
|
17
|
+
id: z.string().min(1),
|
|
18
|
+
new_status: StatusSchema,
|
|
19
|
+
comment: CommentInputSchema,
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
export const toolUpdateTask = (
|
|
23
|
+
rawInput: unknown,
|
|
24
|
+
sessionId: string,
|
|
25
|
+
layer: Layer.Layer<TaskRepository | HookRunner>
|
|
26
|
+
): Promise<{ ok: boolean }> => {
|
|
27
|
+
const input = InputSchema.parse(rawInput)
|
|
28
|
+
const comment = input.comment
|
|
29
|
+
? {
|
|
30
|
+
id: crypto.randomUUID(),
|
|
31
|
+
timestamp: new Date(),
|
|
32
|
+
title: input.comment.title,
|
|
33
|
+
content: input.comment.content,
|
|
34
|
+
reply: "",
|
|
35
|
+
kind: input.comment.kind,
|
|
36
|
+
}
|
|
37
|
+
: null
|
|
38
|
+
|
|
39
|
+
return Effect.runPromise(
|
|
40
|
+
Effect.provide(
|
|
41
|
+
updateTask(input.id, input.new_status, comment, sessionId).pipe(
|
|
42
|
+
Effect.map(() => ({ ok: true }))
|
|
43
|
+
),
|
|
44
|
+
layer
|
|
45
|
+
) as Effect.Effect<{ ok: boolean }, never>
|
|
46
|
+
)
|
|
47
|
+
}
|