@bosun-sh/logbook 1.0.0 → 1.1.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/README.md CHANGED
@@ -34,7 +34,44 @@ logbook is a file-system based kanban board that uses jsonl files to enter one t
34
34
  - the agent can call `create_task(input)` to open a new task in `backlog`, passing `predictedKTokens` so the server derives a Fibonacci estimation automatically
35
35
  - the agent can call `edit_task(id, updates)` to change mutable fields without altering status
36
36
 
37
- each one of these tools has the sole purpose of removing overload from the agent context, handling the _"heavy load"_ programmatically on the MCP server.
37
+ each one of these tools has the sole purpose of removing overload from the agent context, handling the _"heavy load"_ programmatically on the MCP server or CLI.
38
+
39
+ ### cli
40
+
41
+ in addition to the MCP server, logbook provides a CLI for direct command-line usage:
42
+
43
+ ```bash
44
+ # create a task
45
+ logbook create-task --project myproject --milestone v1 --title "Fix bug" \
46
+ --definition-of-done "Bug fixed and tested" --description "Details..." \
47
+ --predicted-k-tokens 3
48
+
49
+ # list tasks
50
+ logbook list-tasks --status in_progress
51
+ logbook list-tasks --status "*"
52
+
53
+ # get current task
54
+ logbook current-task
55
+
56
+ # update task status
57
+ logbook update-task --id <uuid> --new-status in_progress
58
+
59
+ # edit task
60
+ logbook edit-task --id <uuid> --title "New title"
61
+
62
+ # initialize project
63
+ logbook init
64
+ logbook init --force # ensures both AGENTS.md and CLAUDE.md exist
65
+ ```
66
+
67
+ all commands output JSON to stdout for easy parsing:
68
+
69
+ ```json
70
+ {"ok": true, "task": {...}}
71
+ {"ok": false, "error": {"code": -32001, "message": "Task not found", ...}}
72
+ ```
73
+
74
+ see `logbook --help` for full documentation.
38
75
 
39
76
  ## walkthrough
40
77
 
@@ -278,6 +315,8 @@ requires **bun ≥ 1.0.0** as the runtime ([install bun](https://bun.sh)).
278
315
  verify the installation:
279
316
 
280
317
  ```bash
318
+ logbook --version
319
+ # or
281
320
  logbook-mcp --version
282
321
  ```
283
322
 
@@ -287,7 +326,17 @@ for a full onboarding walkthrough see [quickstart.md](quickstart.md).
287
326
 
288
327
  ### quick setup
289
328
 
290
- run `logbook-mcp init` in your project directory to scaffold `tasks.jsonl`, `hooks/`, and print the config snippets for your AI client.
329
+ run `logbook init` in your project directory to scaffold:
330
+ - `tasks.jsonl` — the task store
331
+ - `hooks/` — directory for custom hooks
332
+ - `AGENTS.md` and `CLAUDE.md` — documentation for AI agents
333
+
334
+ by default:
335
+ - if neither AGENTS.md nor CLAUDE.md exists → creates AGENTS.md and symlinks CLAUDE.md to it
336
+ - if only AGENTS.md exists → appends logbook docs to AGENTS.md
337
+ - if only CLAUDE.md exists → appends logbook docs to CLAUDE.md
338
+
339
+ use `logbook init --force` to ensure both files exist (appends to existing, creates/symlinks missing).
291
340
 
292
341
  ### environment variables
293
342
 
@@ -299,11 +348,12 @@ run `logbook-mcp init` in your project directory to scaffold `tasks.jsonl`, `hoo
299
348
 
300
349
  ### gitignore
301
350
 
302
- `tasks.jsonl` and `sessions.json` are runtime files generated by the MCP server — they should not be committed to version control:
351
+ `tasks.jsonl`, `sessions.json`, and `.logbook-session` are runtime files generated by logbook — they should not be committed to version control:
303
352
 
304
353
  ```gitignore
305
354
  tasks.jsonl
306
355
  sessions.json
356
+ .logbook-session
307
357
  ```
308
358
 
309
359
  > note: the logbook repo itself intentionally commits these files for dogfooding — that is the exception, not the rule.
package/package.json CHANGED
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "name": "@bosun-sh/logbook",
3
- "version": "1.0.0",
4
- "description": "File-system kanban board MCP server for AI agents",
3
+ "version": "1.1.0",
4
+ "description": "File-system kanban board CLI and MCP server for AI agents",
5
5
  "type": "module",
6
6
  "bin": {
7
+ "logbook": "src/cli/cli.ts",
7
8
  "logbook-mcp": "src/mcp/server.ts"
8
9
  },
9
10
  "files": [
package/src/cli/cli.ts ADDED
@@ -0,0 +1,489 @@
1
+ #!/usr/bin/env bun
2
+ import { existsSync } from "node:fs"
3
+ import { readFile, writeFile } from "node:fs/promises"
4
+ import { Effect } from "effect"
5
+ import { createLayer, type LayerConfig } from "../infra/layer.js"
6
+ import { taskErrorToMcpError } from "../mcp/error-codes.js"
7
+ import { newSessionId } from "../mcp/session.js"
8
+ import { toolCreateTask } from "../mcp/tool-create-task.js"
9
+ import { toolCurrentTask } from "../mcp/tool-current-task.js"
10
+ import { toolEditTask } from "../mcp/tool-edit-task.js"
11
+ import { toolListTasks } from "../mcp/tool-list-tasks.js"
12
+ import { toolUpdateTask } from "../mcp/tool-update-task.js"
13
+ import { runInit } from "./init.js"
14
+
15
+ const DEFAULT_TASKS_FILE = "./tasks.jsonl"
16
+ const DEFAULT_HOOKS_DIR = "./hooks"
17
+ const SESSION_FILE = ".logbook-session"
18
+
19
+ const helpText = `# logbook - File-system kanban board CLI
20
+
21
+ ## SYNOPSIS
22
+
23
+ logbook <command> [options]
24
+
25
+ ## DESCRIPTION
26
+
27
+ Logbook is a file-system kanban board for AI agents. It tracks tasks across a structured
28
+ lifecycle so agents and humans share a single source of truth without context bloat.
29
+
30
+ ## COMMANDS
31
+
32
+ ### logbook create-task
33
+
34
+ Create a new task in backlog. The task is assigned a unique ID and estimated Fibonacci
35
+ number based on the predicted context size (predictedKTokens).
36
+
37
+ Required arguments:
38
+ --project <name> Project name (e.g., "myproject")
39
+ --milestone <name> Milestone name (e.g., "v1")
40
+ --title <text> Task title
41
+ --definition-of-done <text> What "done" means for this task
42
+ --description <text> Detailed description
43
+ --predicted-k-tokens <n> Estimated context size in thousands of tokens (drives model selection)
44
+
45
+ Optional arguments:
46
+ --priority <n> Priority (higher = more urgent, default: 0)
47
+
48
+ Example:
49
+ logbook create-task --project myproject --milestone v1 --title "Fix bug" \\
50
+ --definition-of-done "Bug fixed and tested" --description "Details..." \\
51
+ --predicted-k-tokens 3
52
+
53
+ ### logbook list-tasks
54
+
55
+ List tasks, optionally filtered by status, project, or milestone.
56
+
57
+ Optional arguments:
58
+ --status <status> Filter by status (default: "in_progress")
59
+ Valid values: backlog, todo, need_info, blocked, in_progress,
60
+ pending_review, done, or "*" for all
61
+ --project <name> Filter by project name
62
+ --milestone <name> Filter by milestone name
63
+
64
+ Examples:
65
+ logbook list-tasks
66
+ logbook list-tasks --status "*"
67
+ logbook list-tasks --status todo --project myproject
68
+
69
+ ### logbook current-task
70
+
71
+ Return the highest-priority in_progress task for this session. If no task is assigned
72
+ to this session, it will claim an unassigned task or transition a todo task to in_progress.
73
+
74
+ This command automatically claims a task if none is currently assigned to the session.
75
+
76
+ Example:
77
+ logbook current-task
78
+
79
+ ### logbook update-task
80
+
81
+ Transition a task's status. Some transitions require a comment.
82
+
83
+ Required arguments:
84
+ --id <uuid> Task ID
85
+ --new-status <status> Target status
86
+
87
+ Optional arguments:
88
+ --comment-title <text> Comment title
89
+ --comment-content <text> Comment body
90
+ --comment-kind <kind> Comment type: "regular" or "need_info" (default: regular)
91
+
92
+ Status transitions require a comment in these cases:
93
+ - Transitioning to need_info (must document what info is needed)
94
+ - Transitioning to blocked (must document why blocked)
95
+ - Moving a second task to in_progress (must explain priority)
96
+ - Transitioning to pending_review (should document review request)
97
+
98
+ Use --comment-title and --comment-content together. To reply to a need_info comment,
99
+ include the original comment's ID via the MCP interface.
100
+
101
+ Examples:
102
+ logbook update-task --id <uuid> --new-status in_progress
103
+ logbook update-task --id <uuid> --new-status pending_review \\
104
+ --comment-title "Review please" --comment-content "Done!"
105
+ logbook update-task --id <uuid> --new-status need_info \\
106
+ --comment-title "Need info" --comment-content "What does X mean?"
107
+
108
+ ### logbook edit-task
109
+
110
+ Edit mutable fields of a task without changing its status.
111
+
112
+ Required arguments:
113
+ --id <uuid> Task ID
114
+
115
+ Optional arguments:
116
+ --title <text> New title
117
+ --description <text> New description
118
+ --definition-of-done <text> New definition of done
119
+ --predicted-k-tokens <n> New predicted context size (will recalculate estimation)
120
+ --priority <n> New priority
121
+
122
+ Example:
123
+ logbook edit-task --id <uuid> --title "New title"
124
+
125
+ ### logbook init
126
+
127
+ Initialize the project: create tasks.jsonl, hooks/, AGENTS.md, and CLAUDE.md if they
128
+ don't exist. If they exist, append logbook documentation to them.
129
+
130
+ By default:
131
+ - If neither AGENTS.md nor CLAUDE.md exists → creates AGENTS.md and symlinks CLAUDE.md to it
132
+ - If only AGENTS.md exists → appends to AGENTS.md
133
+ - If only CLAUDE.md exists → appends to CLAUDE.md
134
+
135
+ Optional arguments:
136
+ --force Force creation/sync of both files. Appends to existing,
137
+ creates/symlinks missing. Ensures both files exist.
138
+
139
+ This command scaffolds the basic structure needed to use logbook.
140
+
141
+ Examples:
142
+ logbook init
143
+ logbook init --force
144
+
145
+ ## TASK LIFECYCLE
146
+
147
+ backlog → todo → in_progress → pending_review → done
148
+
149
+ Side-exits from in_progress:
150
+ - need_info: task needs clarification (return to in_progress once resolved)
151
+ - blocked: task is blocked by external dependency (return to in_progress once resolved)
152
+
153
+ ## ENVIRONMENT
154
+
155
+ LOGBOOK_TASKS_FILE Path to JSONL task store (default: ./tasks.jsonl)
156
+ LOGBOOK_HOOKS_DIR Directory for hook definitions (default: ./hooks)
157
+ LOGBOOK_SESSION_ID Session ID to use (auto-generated if not provided)
158
+
159
+ ## GLOBAL OPTIONS
160
+
161
+ --tasks-file <path> Path to JSONL task store
162
+ --hooks-dir <path> Directory for hook definitions
163
+ --session <id> Session ID to use
164
+ --version, -v Print version
165
+ --help, -h Show this help
166
+
167
+ ## OUTPUT FORMAT
168
+
169
+ All commands output JSON to stdout:
170
+
171
+ Success: { "ok": true, ...result }
172
+ Error: { "ok": false, "error": { "code": <n>, "message": <text>, ... } }
173
+
174
+ Exit code: 0 on success, 1 on error.
175
+ `
176
+
177
+ interface CliArgs {
178
+ tasksFile: string
179
+ hooksDir: string
180
+ sessionId: string | null
181
+ command: string | null
182
+ commandArgs: Record<string, string>
183
+ }
184
+
185
+ const parseArgs = (): CliArgs => {
186
+ const args = process.argv.slice(2)
187
+ const result: CliArgs = {
188
+ tasksFile: process.env.LOGBOOK_TASKS_FILE ?? DEFAULT_TASKS_FILE,
189
+ hooksDir: process.env.LOGBOOK_HOOKS_DIR ?? DEFAULT_HOOKS_DIR,
190
+ sessionId: process.env.LOGBOOK_SESSION_ID ?? null,
191
+ command: null,
192
+ commandArgs: {},
193
+ }
194
+
195
+ let i = 0
196
+ while (i < args.length) {
197
+ const arg = args[i]
198
+ if (arg === "--tasks-file" && i + 1 < args.length) {
199
+ result.tasksFile = args[i + 1] ?? ""
200
+ i += 2
201
+ } else if (arg === "--hooks-dir" && i + 1 < args.length) {
202
+ result.hooksDir = args[i + 1] ?? ""
203
+ i += 2
204
+ } else if (arg === "--session" && i + 1 < args.length) {
205
+ result.sessionId = args[i + 1] ?? null
206
+ i += 2
207
+ } else if (arg === "--version" || arg === "-v") {
208
+ printVersion()
209
+ } else if (arg === "--help" || arg === "-h") {
210
+ printHelp()
211
+ } else if (arg && !arg.startsWith("-")) {
212
+ result.command = arg
213
+ i++
214
+ while (i < args.length) {
215
+ const cmdArg = args[i]
216
+ if (cmdArg?.startsWith("-") && cmdArg.includes("=")) {
217
+ const [key, ...valueParts] = cmdArg.slice(2).split("=")
218
+ if (key) {
219
+ result.commandArgs[key] = valueParts.join("=")
220
+ }
221
+ i++
222
+ } else if (
223
+ cmdArg?.startsWith("-") &&
224
+ i + 1 < args.length &&
225
+ args[i + 1] &&
226
+ !args[i + 1]?.startsWith("-")
227
+ ) {
228
+ result.commandArgs[cmdArg.slice(2)] = args[i + 1] ?? ""
229
+ i += 2
230
+ } else if (
231
+ cmdArg?.startsWith("-") &&
232
+ (i + 1 >= args.length || args[i + 1]?.startsWith("-"))
233
+ ) {
234
+ result.commandArgs[cmdArg.slice(2)] = "true"
235
+ i++
236
+ } else {
237
+ i++
238
+ }
239
+ }
240
+ break
241
+ } else {
242
+ i++
243
+ }
244
+ }
245
+
246
+ return result
247
+ }
248
+
249
+ const printVersion = async (): Promise<void> => {
250
+ const pkg = await import("../../package.json", { with: { type: "json" } })
251
+ console.log(pkg.default.version)
252
+ process.exit(0)
253
+ }
254
+
255
+ const printHelp = (): void => {
256
+ console.log(helpText)
257
+ process.exit(0)
258
+ }
259
+
260
+ const getOrCreateSession = async (explicitSessionId: string | null): Promise<string> => {
261
+ if (explicitSessionId) {
262
+ return explicitSessionId
263
+ }
264
+ try {
265
+ if (existsSync(SESSION_FILE)) {
266
+ const stored = await readFile(SESSION_FILE, "utf8")
267
+ const parsed = JSON.parse(stored)
268
+ if (parsed.sessionId && typeof parsed.sessionId === "string") {
269
+ return parsed.sessionId
270
+ }
271
+ }
272
+ } catch {}
273
+ const newSession = newSessionId()
274
+ await writeFile(SESSION_FILE, JSON.stringify({ sessionId: newSession }), "utf8")
275
+ return newSession
276
+ }
277
+
278
+ const _saveSession = async (sessionId: string): Promise<void> => {
279
+ await writeFile(SESSION_FILE, JSON.stringify({ sessionId }), "utf8")
280
+ }
281
+
282
+ const output = (result: unknown): void => {
283
+ process.stdout.write(`${JSON.stringify(result)}\n`)
284
+ }
285
+
286
+ const outputError = (error: unknown, _command?: string): void => {
287
+ let code = -32603
288
+ let message = "Internal error"
289
+ let hint = ""
290
+ let extra: Record<string, unknown> = {}
291
+
292
+ if (isTaskError(error)) {
293
+ const mcpErr = taskErrorToMcpError(error)
294
+ code = mcpErr.code
295
+ message = mcpErr.message
296
+ extra = mcpErr.data
297
+
298
+ const hints: string[] = []
299
+
300
+ switch (error._tag) {
301
+ case "not_found":
302
+ hints.push("Use: logbook list-tasks --status '*' to find available tasks")
303
+ hints.push("Use: logbook list-tasks --status <status> to list tasks by status")
304
+ break
305
+ case "transition_not_allowed":
306
+ if (error.from && error.to) {
307
+ hints.push(`Use: logbook update-task --id=${error.taskId} --new-status=<valid-status>`)
308
+ if (error.from === "backlog") {
309
+ hints.push("Note: Tasks must move: backlog → todo → in_progress")
310
+ }
311
+ }
312
+ break
313
+ case "validation_error":
314
+ if (error.message.includes("second task to in_progress")) {
315
+ hints.push(
316
+ "Use: logbook update-task --id=<uuid> --new-status=in_progress --comment-title='...' --comment-content='justification'"
317
+ )
318
+ } else if (error.message.includes("need_info") && error.message.includes("reply")) {
319
+ hints.push("Include a non-empty reply in your comment to proceed")
320
+ }
321
+ break
322
+ case "missing_comment":
323
+ hints.push(
324
+ "Use: logbook update-task --id=<uuid> --new-status=<status> --comment-title='...' --comment-content='...'"
325
+ )
326
+ break
327
+ case "no_current_task":
328
+ hints.push("Use: logbook list-tasks --status=todo to find available tasks")
329
+ hints.push("Use: logbook create-task ... to create a new task")
330
+ break
331
+ case "conflict":
332
+ hints.push(
333
+ "Use a different task ID or check existing tasks with: logbook list-tasks --status='*'"
334
+ )
335
+ break
336
+ }
337
+
338
+ if (hints.length > 0) {
339
+ hint = `\n\nHints:\n${hints.map((h) => ` - ${h}`).join("\n")}`
340
+ }
341
+ } else if (error instanceof Error) {
342
+ message = error.message
343
+ if (message.includes("Missing required")) {
344
+ hint = "\n\nRun: logbook <command> --help for usage information"
345
+ }
346
+ }
347
+
348
+ const fullMessage = message + hint
349
+ output({ ok: false, error: { code, message: fullMessage, ...extra } })
350
+ process.exit(1)
351
+ }
352
+
353
+ const isTaskError = (e: unknown): e is import("../domain/types.js").TaskError =>
354
+ typeof e === "object" &&
355
+ e !== null &&
356
+ typeof (e as { _tag?: unknown })._tag === "string" &&
357
+ [
358
+ "not_found",
359
+ "transition_not_allowed",
360
+ "validation_error",
361
+ "missing_comment",
362
+ "conflict",
363
+ "no_current_task",
364
+ ].includes((e as { _tag: string })._tag)
365
+
366
+ const runCommand = async (args: CliArgs): Promise<void> => {
367
+ const sessionId = await getOrCreateSession(args.sessionId)
368
+ const config: LayerConfig = {
369
+ tasksFile: args.tasksFile,
370
+ hooksDir: args.hooksDir,
371
+ }
372
+ const layer = await createLayer(config)
373
+
374
+ const dispatch = async (): Promise<unknown> => {
375
+ switch (args.command) {
376
+ case "init": {
377
+ await runInit(process.cwd(), { force: !!args.commandArgs.force })
378
+ return { ok: true }
379
+ }
380
+ case "create-task": {
381
+ const input = {
382
+ project: args.commandArgs.project,
383
+ milestone: args.commandArgs.milestone,
384
+ title: args.commandArgs.title,
385
+ definition_of_done: args.commandArgs["definition-of-done"],
386
+ description: args.commandArgs.description,
387
+ predictedKTokens: parseInt(args.commandArgs["predicted-k-tokens"] ?? "0", 10),
388
+ priority: args.commandArgs.priority ? parseInt(args.commandArgs.priority, 10) : 0,
389
+ }
390
+ if (
391
+ !input.project ||
392
+ !input.milestone ||
393
+ !input.title ||
394
+ !input.definition_of_done ||
395
+ !input.description ||
396
+ !input.predictedKTokens
397
+ ) {
398
+ throw new Error(
399
+ "Missing required arguments: project, milestone, title, definition-of-done, description, predicted-k-tokens"
400
+ )
401
+ }
402
+ return toolCreateTask(input, sessionId, layer)
403
+ }
404
+ case "list-tasks": {
405
+ const input = {
406
+ status: args.commandArgs.status ?? "in_progress",
407
+ project: args.commandArgs.project,
408
+ milestone: args.commandArgs.milestone,
409
+ }
410
+ return toolListTasks(input, layer)
411
+ }
412
+ case "current-task": {
413
+ return toolCurrentTask(sessionId, layer)
414
+ }
415
+ case "update-task": {
416
+ const input: Record<string, unknown> = {
417
+ id: args.commandArgs.id,
418
+ new_status: args.commandArgs["new-status"],
419
+ }
420
+ if (args.commandArgs["comment-title"] || args.commandArgs["comment-content"]) {
421
+ input.comment = {
422
+ title: args.commandArgs["comment-title"] ?? "",
423
+ content: args.commandArgs["comment-content"] ?? "",
424
+ kind: args.commandArgs["comment-kind"] ?? "regular",
425
+ }
426
+ }
427
+ if (!input.id || !input.new_status) {
428
+ throw new Error("Missing required arguments: id, new-status")
429
+ }
430
+ return toolUpdateTask(input, sessionId, layer)
431
+ }
432
+ case "edit-task": {
433
+ const input: Record<string, unknown> = { id: args.commandArgs.id }
434
+ if (args.commandArgs.title) input.title = args.commandArgs.title
435
+ if (args.commandArgs.description) input.description = args.commandArgs.description
436
+ if (args.commandArgs["definition-of-done"])
437
+ input.definition_of_done = args.commandArgs["definition-of-done"]
438
+ if (args.commandArgs["predicted-k-tokens"])
439
+ input.predictedKTokens = parseInt(args.commandArgs["predicted-k-tokens"], 10)
440
+ if (args.commandArgs.priority) input.priority = parseInt(args.commandArgs.priority, 10)
441
+ if (!input.id) {
442
+ throw new Error("Missing required argument: id")
443
+ }
444
+ return toolEditTask(input, layer)
445
+ }
446
+ default:
447
+ throw new Error(`Unknown command: ${args.command ?? "none"}`)
448
+ }
449
+ }
450
+
451
+ try {
452
+ const result = await dispatch()
453
+ output({ ok: true, ...(result as object) })
454
+ } catch (err) {
455
+ outputError(err, args.command ?? undefined)
456
+ }
457
+ }
458
+
459
+ const cleanup = async (sessionId: string): Promise<void> => {
460
+ try {
461
+ const tasksFile = process.env.LOGBOOK_TASKS_FILE ?? DEFAULT_TASKS_FILE
462
+ const { PidSessionRegistry } = await import("../infra/pid-session-registry.js")
463
+ const registry = new PidSessionRegistry(tasksFile)
464
+ await Effect.runPromise(registry.deregister(sessionId))
465
+ } catch {}
466
+ }
467
+
468
+ const main = async (): Promise<void> => {
469
+ const args = parseArgs()
470
+
471
+ if (!args.command) {
472
+ printHelp()
473
+ }
474
+
475
+ const sessionId = await getOrCreateSession(args.sessionId)
476
+
477
+ process.on("SIGINT", async () => {
478
+ await cleanup(sessionId)
479
+ process.exit(0)
480
+ })
481
+ process.on("SIGTERM", async () => {
482
+ await cleanup(sessionId)
483
+ process.exit(0)
484
+ })
485
+
486
+ await runCommand(args)
487
+ }
488
+
489
+ main()
package/src/cli/init.ts CHANGED
@@ -1,9 +1,68 @@
1
- import { access, mkdir, writeFile } from "node:fs/promises"
1
+ import { access, lstat, mkdir, readFile, symlink, writeFile } from "node:fs/promises"
2
2
  import { join } from "node:path"
3
3
 
4
- // ---------------------------------------------------------------------------
5
- // Config snippet constants (pure — no side effects)
6
- // ---------------------------------------------------------------------------
4
+ const CLI_DOC_CONTENT = `# Logbook CLI
5
+
6
+ File-system kanban board for AI agents.
7
+
8
+ ## Commands
9
+
10
+ | Command | Description |
11
+ |---------|-------------|
12
+ | \`logbook create-task\` | Create a new task in backlog |
13
+ | \`logbook list-tasks\` | List tasks, optionally filtered by status |
14
+ | \`logbook current-task\` | Get current in-progress task for this session |
15
+ | \`logbook update-task\` | Transition task status |
16
+ | \`logbook edit-task\` | Edit task fields without changing status |
17
+ | \`logbook init\` | Initialize project |
18
+
19
+ ## Task Lifecycle
20
+
21
+ \`backlog → todo → in_progress → pending_review → done\`
22
+
23
+ Side-exits: \`in_progress → need_info\`, \`blocked\` (return to \`in_progress\`)
24
+
25
+ ## Usage Examples
26
+
27
+ ### Create a task
28
+ \`\`\`bash
29
+ logbook create-task --project myproject --milestone v1 --title "Fix bug" \\
30
+ --definition-of-done "Bug fixed and tested" --description "Details..." \\
31
+ --predicted-k-tokens 3
32
+ \`\`\`
33
+
34
+ ### List tasks
35
+ \`\`\`bash
36
+ logbook list-tasks --status in_progress
37
+ logbook list-tasks --status "*"
38
+ logbook list-tasks --status todo --project myproject
39
+ \`\`\`
40
+
41
+ ### Get current task
42
+ \`\`\`bash
43
+ logbook current-task
44
+ \`\`\`
45
+
46
+ ### Update task status
47
+ \`\`\`bash
48
+ logbook update-task --id <uuid> --new-status in_progress
49
+ logbook update-task --id <uuid> --new-status need_info \\
50
+ --comment-title "Need info" --comment-content "What does X mean?"
51
+ \`\`\`
52
+
53
+ ### Edit task
54
+ \`\`\`bash
55
+ logbook edit-task --id <uuid> --title "New title"
56
+ \`\`\`
57
+
58
+ ## Environment Variables
59
+
60
+ | Variable | Default | Description |
61
+ |----------|---------|-------------|
62
+ | \`LOGBOOK_TASKS_FILE\` | \`./tasks.jsonl\` | Path to JSONL task store |
63
+ | \`LOGBOOK_HOOKS_DIR\` | \`./hooks\` | Directory for hook definitions |
64
+ | \`LOGBOOK_SESSION_ID\` | auto-generated | Session ID to use |
65
+ `
7
66
 
8
67
  const CLAUDE_CODE_SNIPPET = `Claude Code — add to .claude/settings.json:
9
68
  {
@@ -27,18 +86,15 @@ const OPENCODE_SNIPPET = `OpenCode — add to opencode.json:
27
86
 
28
87
  const GITIGNORE_SNIPPET = `.gitignore — add these runtime files:
29
88
  tasks.jsonl
30
- sessions.json`
89
+ sessions.json
90
+ .logbook-session`
31
91
 
32
92
  const NEXT_STEPS = `Next steps:
33
- 1. Add the config snippet for your AI client
34
- 2. Run: LOGBOOK_TASKS_FILE=./tasks.jsonl logbook-mcp
35
- 3. In your AI client, call current_task() to verify — expected: no_current_task
93
+ 1. Add the config snippet for your AI client (MCP) or use CLI directly
94
+ 2. Run: LOGBOOK_TASKS_FILE=./tasks.jsonl logbook init
95
+ 3. See AGENTS.md for CLI commands reference
36
96
  4. See quickstart.md for the full walkthrough`
37
97
 
38
- // ---------------------------------------------------------------------------
39
- // Side-effecting helpers (file I/O at boundary)
40
- // ---------------------------------------------------------------------------
41
-
42
98
  const fileExists = async (path: string): Promise<boolean> => {
43
99
  try {
44
100
  await access(path)
@@ -48,6 +104,15 @@ const fileExists = async (path: string): Promise<boolean> => {
48
104
  }
49
105
  }
50
106
 
107
+ const isSymlink = async (path: string): Promise<boolean> => {
108
+ try {
109
+ const stats = await lstat(path)
110
+ return stats.isSymbolicLink()
111
+ } catch {
112
+ return false
113
+ }
114
+ }
115
+
51
116
  const scaffoldTasksFile = async (cwd: string): Promise<void> => {
52
117
  const path = join(cwd, "tasks.jsonl")
53
118
  if (await fileExists(path)) {
@@ -68,6 +133,81 @@ const scaffoldHooksDir = async (cwd: string): Promise<void> => {
68
133
  console.log("✓ hooks/ created")
69
134
  }
70
135
 
136
+ const appendLogbookDocs = async (path: string, isAgents: boolean): Promise<void> => {
137
+ const existing = await readFile(path, "utf8")
138
+ if (isAgents) {
139
+ const separator = "\n\n---\n\n"
140
+ await writeFile(path, `${existing}${separator}${CLI_DOC_CONTENT}`, "utf8")
141
+ } else {
142
+ const cliSection = `
143
+
144
+ ---
145
+
146
+ ## Logbook
147
+
148
+ ${CLI_DOC_CONTENT}
149
+ `
150
+ await writeFile(path, `${existing}${cliSection}`, "utf8")
151
+ }
152
+ }
153
+
154
+ const ensureBothDocs = async (cwd: string, force: boolean): Promise<void> => {
155
+ const agentsPath = join(cwd, "AGENTS.md")
156
+ const claudePath = join(cwd, "CLAUDE.md")
157
+
158
+ const agentsExists = await fileExists(agentsPath)
159
+ const claudeExists = await fileExists(claudePath)
160
+ const agentsIsSymlink = await isSymlink(agentsPath)
161
+ const claudeIsSymlink = await isSymlink(claudePath)
162
+
163
+ if (!force) {
164
+ if (agentsExists && !agentsIsSymlink) {
165
+ console.log("✓ AGENTS.md already exists, appending logbook documentation")
166
+ await appendLogbookDocs(agentsPath, true)
167
+ }
168
+ if (claudeExists && !claudeIsSymlink) {
169
+ console.log("✓ CLAUDE.md already exists, appending logbook documentation")
170
+ await appendLogbookDocs(claudePath, false)
171
+ }
172
+ if (!agentsExists && !claudeExists) {
173
+ await writeFile(agentsPath, CLI_DOC_CONTENT, "utf8")
174
+ console.log("✓ AGENTS.md created")
175
+ await symlink("AGENTS.md", claudePath)
176
+ console.log("✓ CLAUDE.md created (symlink to AGENTS.md)")
177
+ }
178
+ return
179
+ }
180
+
181
+ if (agentsExists && !agentsIsSymlink) {
182
+ await appendLogbookDocs(agentsPath, true)
183
+ } else if (!agentsExists) {
184
+ await writeFile(agentsPath, CLI_DOC_CONTENT, "utf8")
185
+ console.log("✓ AGENTS.md created")
186
+ }
187
+
188
+ if (claudeExists && !claudeIsSymlink) {
189
+ await appendLogbookDocs(claudePath, false)
190
+ } else if (!claudeExists) {
191
+ const targetExists = await fileExists(agentsPath)
192
+ if (targetExists) {
193
+ await symlink("AGENTS.md", claudePath)
194
+ console.log("✓ CLAUDE.md created (symlink to AGENTS.md)")
195
+ } else {
196
+ await writeFile(
197
+ claudePath,
198
+ `# Logbook
199
+
200
+ ${CLI_DOC_CONTENT}
201
+ `,
202
+ "utf8"
203
+ )
204
+ console.log("✓ CLAUDE.md created")
205
+ }
206
+ } else if (claudeIsSymlink) {
207
+ console.log("✓ CLAUDE.md is already a symlink to AGENTS.md, skipping")
208
+ }
209
+ }
210
+
71
211
  const printSnippets = (): void => {
72
212
  console.log("")
73
213
  console.log(CLAUDE_CODE_SNIPPET)
@@ -79,12 +219,12 @@ const printSnippets = (): void => {
79
219
  console.log(NEXT_STEPS)
80
220
  }
81
221
 
82
- // ---------------------------------------------------------------------------
83
- // Entry point
84
- // ---------------------------------------------------------------------------
85
-
86
- export const runInit = async (cwd: string = process.cwd()): Promise<void> => {
222
+ export const runInit = async (
223
+ cwd: string = process.cwd(),
224
+ options: { force?: boolean } = {}
225
+ ): Promise<void> => {
87
226
  await scaffoldTasksFile(cwd)
88
227
  await scaffoldHooksDir(cwd)
228
+ await ensureBothDocs(cwd, options.force ?? false)
89
229
  printSnippets()
90
230
  }
@@ -0,0 +1,34 @@
1
+ import { Layer } from "effect"
2
+ import { executeHooks } from "../hook/hook-executor.js"
3
+ import type { HookEvent } from "../hook/ports.js"
4
+ import { HookRunner } from "../hook/ports.js"
5
+ import { loadHookConfigs } from "../infra/hook-config-loader.js"
6
+ import { JsonlTaskRepository } from "../infra/jsonl-task-repository.js"
7
+ import { PidSessionRegistry } from "../infra/pid-session-registry.js"
8
+ import { TaskRepository } from "../task/ports.js"
9
+ import { SessionRegistry } from "../task/session-registry.js"
10
+
11
+ export interface LayerConfig {
12
+ tasksFile: string
13
+ hooksDir: string
14
+ }
15
+
16
+ export const createLayer = async (
17
+ config: LayerConfig
18
+ ): Promise<Layer.Layer<TaskRepository | HookRunner | SessionRegistry>> => {
19
+ const configs = await loadHookConfigs(config.hooksDir)
20
+ const repo = new JsonlTaskRepository(config.tasksFile)
21
+ const registry = new PidSessionRegistry(config.tasksFile)
22
+
23
+ const hookRunnerImpl: HookRunner = {
24
+ run: (event: HookEvent) => executeHooks(event, configs),
25
+ }
26
+
27
+ const repoLayer: Layer.Layer<TaskRepository> = Layer.succeed(TaskRepository, repo)
28
+ const fullLayer: Layer.Layer<TaskRepository | HookRunner | SessionRegistry> = Layer.merge(
29
+ Layer.merge(repoLayer, Layer.succeed(HookRunner, hookRunnerImpl)),
30
+ Layer.succeed(SessionRegistry, registry)
31
+ )
32
+
33
+ return fullLayer
34
+ }
package/src/mcp/server.ts CHANGED
@@ -1,15 +1,9 @@
1
1
  #!/usr/bin/env bun
2
2
  import { createInterface } from "node:readline"
3
- import { Effect, Layer } from "effect"
3
+ import { Effect } from "effect"
4
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"
5
+ import { createLayer } from "../infra/layer.js"
10
6
  import { PidSessionRegistry } from "../infra/pid-session-registry.js"
11
- import { TaskRepository } from "../task/ports.js"
12
- import { SessionRegistry } from "../task/session-registry.js"
13
7
  import { taskErrorToMcpError } from "./error-codes.js"
14
8
  import { newSessionId } from "./session.js"
15
9
  import { toolCreateTask } from "./tool-create-task.js"
@@ -218,20 +212,9 @@ export const startServer = async (): Promise<void> => {
218
212
  const tasksFile = process.env.LOGBOOK_TASKS_FILE ?? "./tasks.jsonl"
219
213
  const hooksDir = process.env.LOGBOOK_HOOKS_DIR ?? "./hooks"
220
214
 
221
- const configs = await loadHookConfigs(hooksDir)
222
- const repo = new JsonlTaskRepository(tasksFile)
215
+ const fullLayer = await createLayer({ tasksFile, hooksDir })
223
216
  const registry = new PidSessionRegistry(tasksFile)
224
217
 
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
218
  const sessionId = newSessionId()
236
219
  await Effect.runPromise(registry.register(sessionId, process.pid))
237
220
 
@@ -256,15 +239,15 @@ export const startServer = async (): Promise<void> => {
256
239
  return { content: [{ type: "text", text: JSON.stringify(result) }] }
257
240
  }
258
241
  case "list_tasks":
259
- return toolListTasks(params, repoLayer)
242
+ return toolListTasks(params, fullLayer)
260
243
  case "current_task":
261
244
  return toolCurrentTask(sessionId, fullLayer)
262
245
  case "update_task":
263
246
  return toolUpdateTask(params, sessionId, fullLayer)
264
247
  case "create_task":
265
- return toolCreateTask(params, sessionId, repoLayer)
248
+ return toolCreateTask(params, sessionId, fullLayer)
266
249
  case "edit_task":
267
- return toolEditTask(params, repoLayer)
250
+ return toolEditTask(params, fullLayer)
268
251
  default:
269
252
  return Promise.reject(new MethodNotFoundError(method))
270
253
  }