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,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
+ }