@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.
@@ -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
+ }