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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 bosun.sh
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# logbook: kanban for ai agents
|
|
2
|
+
|
|
3
|
+
logbook is a kanban board implementation for autonomous agentic development, focusing on autonomous development and context window management.
|
|
4
|
+
|
|
5
|
+
## problem
|
|
6
|
+
|
|
7
|
+
ai agents changed the way software teams worked, and with specification-driven development we encounter a rift: **agents don't manage their tasks as we do**.
|
|
8
|
+
|
|
9
|
+
### what's the issue with this?
|
|
10
|
+
|
|
11
|
+
- hard for humans to track autonomous work properly: **"do you know what specific tasks your agent did?"**
|
|
12
|
+
- hard for agents to track tasks in-progress and done: **not a centralized way to track tasks so each instance haves to figure this out**
|
|
13
|
+
- existing tools add too much overload and are human-centered: **if an agent is going to use it, then it should be tailored for agents**
|
|
14
|
+
|
|
15
|
+
## solution
|
|
16
|
+
|
|
17
|
+
logbook is a file-system based kanban board that uses jsonl files to enter one task per line in a structured and clean approach and gives the agent the right tools to use it:
|
|
18
|
+
|
|
19
|
+
### tools
|
|
20
|
+
|
|
21
|
+
- the agent can call `list_tasks(status)` and receive a list of the tasks in that status _(in_progress by default)_
|
|
22
|
+
- the agent can call `current_task()` and receive the current task _(only task in_progress)_
|
|
23
|
+
- the agent can call `update_task(id, new_status, new_comment)` and update the tasks to the new status adding a comment to justify it
|
|
24
|
+
|
|
25
|
+
each one of this tools-and more to add-have the sole purpose of removing overload from the agent context, handling the _"heavy load"_ programatically on the mcp server.
|
|
26
|
+
|
|
27
|
+
## architecture
|
|
28
|
+
|
|
29
|
+
- **runtime**: Bun / TypeScript
|
|
30
|
+
- **effect system**: Effect.ts — all async operations and errors are modeled as `Effect<A, E, R>`
|
|
31
|
+
- **architecture**: hexagonal (ports & adapters), organized by vertical slices per domain concept (task, hook)
|
|
32
|
+
- **validation**: Zod at every system boundary (MCP input, filesystem reads)
|
|
33
|
+
- **persistence**: JSONL — one task per line, append-only writes, full file scan for reads
|
|
34
|
+
|
|
35
|
+
JSONL was chosen for simplicity and agent-friendliness: a single line = a single task makes partial reads and diffs readable without tooling.
|
|
36
|
+
|
|
37
|
+
### hooks
|
|
38
|
+
|
|
39
|
+
besides the tools that the agent call manually, each action performed in the kanban can have automatic _hooks_ executed right before or after.
|
|
40
|
+
the default hooks include:
|
|
41
|
+
|
|
42
|
+
- after moving a task to `need_info`, the user receives a notification with the comment left to be able to answer the question.
|
|
43
|
+
- after moving a task to `pending_review`, a reviewer sub-agent spawns and a review task is automatically generated for it.
|
|
44
|
+
- when a second task is moved to `in_progress`, a built-in hook fires and requires a comment justifying the overlap before proceeding.
|
|
45
|
+
|
|
46
|
+
but hooks can also be defined by the user as scripts in any language as long as it's installed in the system, under the "hooks/" directory, following this structure:
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
hooks/
|
|
50
|
+
└── example_hook/
|
|
51
|
+
├── config.yml
|
|
52
|
+
└── script.ts
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
a minimal `config.yml` looks like:
|
|
56
|
+
|
|
57
|
+
```yaml
|
|
58
|
+
# config.yml
|
|
59
|
+
event: task.status_changed # lifecycle event that triggers the hook
|
|
60
|
+
condition: "new_status == 'need_info'" # optional; JS-like expression
|
|
61
|
+
timeout_ms: 5000 # optional; default 5000
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
you can base your config.yml in the default hooks-which have complete configuration files.
|
|
65
|
+
|
|
66
|
+
> note: as mentioned, you can change .ts for any language, but the .yml / .yaml is required for configuration.
|
|
67
|
+
|
|
68
|
+
#### why hooks?
|
|
69
|
+
|
|
70
|
+
hooks don't need to store information from one execution to the other, so the main principle here is: **"execute and forget"**, this way we can focus on the kanban and actual tasks.
|
|
71
|
+
|
|
72
|
+
## contracts
|
|
73
|
+
|
|
74
|
+
the core types the server operates on:
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
type Agent = {
|
|
78
|
+
id: string, // session_id assigned by the server on connection
|
|
79
|
+
title: string,
|
|
80
|
+
description: string
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
type Status = 'backlog' | 'todo' | 'need_info' | 'blocked' | 'in_progress' | 'pending_review' | 'done'
|
|
84
|
+
|
|
85
|
+
type Comment = {
|
|
86
|
+
id: string,
|
|
87
|
+
timestamp: Date,
|
|
88
|
+
title: string,
|
|
89
|
+
content: string,
|
|
90
|
+
reply: string, // user's reply, populated when responding to a need_info comment
|
|
91
|
+
kind: 'need_info' | 'regular' // drives the reply cycle — only need_info comments accept replies
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
type Task = {
|
|
95
|
+
project: string,
|
|
96
|
+
milestone: string,
|
|
97
|
+
id: string,
|
|
98
|
+
title: string,
|
|
99
|
+
definition_of_done: string,
|
|
100
|
+
description: string,
|
|
101
|
+
estimation: number, // fibonacci scale
|
|
102
|
+
comments: Comment[],
|
|
103
|
+
assignee: Agent,
|
|
104
|
+
status: Status,
|
|
105
|
+
in_progress_since?: Date // set when task enters in_progress; drives FIFO ordering in current_task
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// status defaults to 'in_progress'
|
|
109
|
+
type ListTasks = (status: Status | '*') => Task[]
|
|
110
|
+
|
|
111
|
+
// returns the highest-priority in_progress task for the current session.
|
|
112
|
+
// if a second task is moved to in_progress, a built-in hook fires and
|
|
113
|
+
// requires a comment justifying the overlap.
|
|
114
|
+
type GetCurrentTask = () => Task
|
|
115
|
+
|
|
116
|
+
// transitions a task to a new status; sessionId is injected server-side
|
|
117
|
+
type UpdateTask = (id: string, new_status: Status, comment: Comment | null, sessionId: string) => void
|
|
118
|
+
|
|
119
|
+
// creates a new task in backlog assigned to the calling session
|
|
120
|
+
type CreateTask = (input: CreateTaskInput, sessionId: string) => Task
|
|
121
|
+
|
|
122
|
+
// edits mutable fields without changing status
|
|
123
|
+
type EditTask = (id: string, updates: EditTaskInput) => Task
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
each MCP session is treated as a distinct agent instance. the server assigns a `session_id` on connection and uses it to scope `GetCurrentTask` — no explicit agent ID needs to be passed by the caller.
|
|
127
|
+
|
|
128
|
+
## security
|
|
129
|
+
|
|
130
|
+
### hook conditions are trusted code
|
|
131
|
+
|
|
132
|
+
hook `config.yml` files support an optional `condition` field (e.g. `"new_status == 'pending_review'"`). these conditions are compiled and evaluated as live JavaScript at runtime — equivalent in trust level to a shell script.
|
|
133
|
+
|
|
134
|
+
**what this means for you:**
|
|
135
|
+
|
|
136
|
+
- **only add hooks from sources you trust.** a malicious `config.yml` condition can execute arbitrary code in the process that runs the MCP server.
|
|
137
|
+
- **do not expose `LOGBOOK_HOOKS_DIR` to external write access.** if an untrusted process can write files under the hooks directory, it can inject conditions that execute as the MCP server's user.
|
|
138
|
+
- the built-in hooks shipped with logbook are safe — they use simple equality checks (`new_status == 'need_info'`).
|
|
139
|
+
- if a condition throws or is malformed, the hook is skipped silently and execution continues — it fails safe.
|
|
140
|
+
|
|
141
|
+
the security model here is the same as running a `Makefile` or a `.husky/` script: filesystem-level trust. as long as you control what goes into your hooks directory, you are safe.
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { readFile } from "node:fs/promises"
|
|
3
|
+
|
|
4
|
+
const taskId = process.env.LOGBOOK_TASK_ID ?? ""
|
|
5
|
+
const dataFile = process.env.LOGBOOK_TASKS_FILE ?? "./tasks.jsonl"
|
|
6
|
+
|
|
7
|
+
if (taskId === "") process.exit(0)
|
|
8
|
+
|
|
9
|
+
const readLines = async (filePath: string): Promise<readonly string[]> => {
|
|
10
|
+
const content = await readFile(filePath, "utf8").catch((e: unknown) => {
|
|
11
|
+
if (isEnoent(e)) return ""
|
|
12
|
+
throw e
|
|
13
|
+
})
|
|
14
|
+
return content.split("\n").filter((l) => l.trim() !== "")
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface RawComment {
|
|
18
|
+
id: string
|
|
19
|
+
timestamp: string
|
|
20
|
+
title: string
|
|
21
|
+
content: string
|
|
22
|
+
reply: string
|
|
23
|
+
kind: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface RawTask {
|
|
27
|
+
id: string
|
|
28
|
+
comments: RawComment[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const parseTask = (line: string): RawTask | null => {
|
|
32
|
+
try {
|
|
33
|
+
return JSON.parse(line) as RawTask
|
|
34
|
+
} catch {
|
|
35
|
+
return null
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const findBlockingComment = (task: RawTask): RawComment | null => {
|
|
40
|
+
// Find the most recent need_info comment with an empty reply
|
|
41
|
+
for (let i = task.comments.length - 1; i >= 0; i--) {
|
|
42
|
+
const comment = task.comments[i]
|
|
43
|
+
if (comment !== undefined && comment.kind === "need_info" && comment.reply === "") {
|
|
44
|
+
return comment
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const isEnoent = (e: unknown): boolean =>
|
|
51
|
+
typeof e === "object" && e !== null && (e as { code?: unknown }).code === "ENOENT"
|
|
52
|
+
|
|
53
|
+
const lines = await readLines(dataFile)
|
|
54
|
+
|
|
55
|
+
let found: RawTask | null = null
|
|
56
|
+
for (const line of lines) {
|
|
57
|
+
const task = parseTask(line)
|
|
58
|
+
if (task !== null && task.id === taskId) {
|
|
59
|
+
found = task
|
|
60
|
+
break
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (found === null) process.exit(0)
|
|
65
|
+
|
|
66
|
+
const comment = findBlockingComment(found)
|
|
67
|
+
if (comment === null) process.exit(0)
|
|
68
|
+
|
|
69
|
+
process.stdout.write(`[need_info] Task ${taskId}: ${comment.title}\n${comment.content}\n`)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { appendFile, readFile } from "node:fs/promises"
|
|
3
|
+
|
|
4
|
+
const taskId = process.env.LOGBOOK_TASK_ID ?? ""
|
|
5
|
+
const dataFile = process.env.LOGBOOK_TASKS_FILE ?? "./tasks.jsonl"
|
|
6
|
+
|
|
7
|
+
if (taskId === "") process.exit(0)
|
|
8
|
+
|
|
9
|
+
const readLines = async (filePath: string): Promise<readonly string[]> => {
|
|
10
|
+
const content = await readFile(filePath, "utf8").catch((e: unknown) => {
|
|
11
|
+
if (isEnoent(e)) return ""
|
|
12
|
+
throw e
|
|
13
|
+
})
|
|
14
|
+
return content.split("\n").filter((l) => l.trim() !== "")
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface RawAgent {
|
|
18
|
+
id: string
|
|
19
|
+
title: string
|
|
20
|
+
description: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface RawTask {
|
|
24
|
+
project: string
|
|
25
|
+
milestone: string
|
|
26
|
+
id: string
|
|
27
|
+
title: string
|
|
28
|
+
definition_of_done: string
|
|
29
|
+
description: string
|
|
30
|
+
estimation: number
|
|
31
|
+
comments: unknown[]
|
|
32
|
+
assignee: RawAgent
|
|
33
|
+
status: string
|
|
34
|
+
in_progress_since?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const parseTask = (line: string): RawTask | null => {
|
|
38
|
+
try {
|
|
39
|
+
return JSON.parse(line) as RawTask
|
|
40
|
+
} catch {
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const isEnoent = (e: unknown): boolean =>
|
|
46
|
+
typeof e === "object" && e !== null && (e as { code?: unknown }).code === "ENOENT"
|
|
47
|
+
|
|
48
|
+
const lines = await readLines(dataFile)
|
|
49
|
+
|
|
50
|
+
let original: RawTask | null = null
|
|
51
|
+
for (const line of lines) {
|
|
52
|
+
const task = parseTask(line)
|
|
53
|
+
if (task !== null && task.id === taskId) {
|
|
54
|
+
original = task
|
|
55
|
+
break
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (original === null) process.exit(0)
|
|
60
|
+
|
|
61
|
+
const reviewId = `review-${original.id}`
|
|
62
|
+
|
|
63
|
+
// Idempotency check: skip if a task with the review id already exists
|
|
64
|
+
const alreadyExists = lines.some((line) => {
|
|
65
|
+
const task = parseTask(line)
|
|
66
|
+
return task !== null && task.id === reviewId
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
if (alreadyExists) process.exit(0)
|
|
70
|
+
|
|
71
|
+
const reviewTask: RawTask = {
|
|
72
|
+
project: original.project,
|
|
73
|
+
milestone: original.milestone,
|
|
74
|
+
id: reviewId,
|
|
75
|
+
title: `Review: ${original.title}`,
|
|
76
|
+
definition_of_done: "Review approved",
|
|
77
|
+
description: `Review task for ${original.id}`,
|
|
78
|
+
estimation: 1,
|
|
79
|
+
comments: [],
|
|
80
|
+
assignee: original.assignee,
|
|
81
|
+
status: "todo",
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
await appendFile(dataFile, `${JSON.stringify(reviewTask)}\n`, "utf8")
|
|
85
|
+
|
|
86
|
+
const { execSync } = await import("node:child_process")
|
|
87
|
+
const path = await import("node:path")
|
|
88
|
+
const projectRoot = path.dirname(process.env.LOGBOOK_TASKS_FILE ?? "./tasks.jsonl")
|
|
89
|
+
const mcpConfig = path.join(projectRoot, ".claude/mcp-config.json")
|
|
90
|
+
execSync(
|
|
91
|
+
`claude --model claude-haiku-4-5-20251001 --mcp-config ${mcpConfig} --agent reviewer --task "review task ${reviewId}"`,
|
|
92
|
+
{ stdio: "inherit", env: { ...process.env, LOGBOOK_TASKS_FILE: dataFile } }
|
|
93
|
+
)
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "logbook-mcp",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "File-system kanban board MCP server for AI agents",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"logbook-mcp": "src/mcp/server.ts"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src/",
|
|
11
|
+
"hooks/",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"bun": ">=1.0.0"
|
|
16
|
+
},
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public",
|
|
19
|
+
"registry": "https://registry.npmjs.org/"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"start": "bun src/mcp/server.ts",
|
|
23
|
+
"test": "bun test",
|
|
24
|
+
"test:watch": "bun test --watch",
|
|
25
|
+
"test:unit": "bun test tests/unit/",
|
|
26
|
+
"test:e2e": "bun test tests/e2e/",
|
|
27
|
+
"typecheck": "tsc --noEmit",
|
|
28
|
+
"lint": "biome lint .",
|
|
29
|
+
"lint:fix": "biome lint --write .",
|
|
30
|
+
"format": "biome format --write .",
|
|
31
|
+
"check": "biome check .",
|
|
32
|
+
"prepare": "husky",
|
|
33
|
+
"prepublishOnly": "bun run typecheck",
|
|
34
|
+
"publish": "bun run typecheck && npm pack --dry-run && npm publish --ignore-scripts"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"effect": "^3.12.0",
|
|
38
|
+
"zod": "^3.24.1"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@biomejs/biome": "latest",
|
|
42
|
+
"@types/bun": "latest",
|
|
43
|
+
"husky": "^9.0.0",
|
|
44
|
+
"typescript": "^5.7.3"
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Effect } from "effect"
|
|
2
|
+
import type { TaskError } from "./types.js"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Validates that n is a positive Fibonacci number.
|
|
6
|
+
* Returns Effect.succeed(void) when valid,
|
|
7
|
+
* Effect.fail({ _tag: 'validation_error', message: 'estimation must be a Fibonacci number' }) otherwise.
|
|
8
|
+
*
|
|
9
|
+
* Algorithm: n is Fibonacci iff one of 5n²+4 or 5n²-4 is a perfect square.
|
|
10
|
+
*/
|
|
11
|
+
export const validateFibonacci = (n: number): Effect.Effect<void, TaskError> => {
|
|
12
|
+
// Must be a positive integer
|
|
13
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
14
|
+
return Effect.fail({
|
|
15
|
+
_tag: "validation_error",
|
|
16
|
+
message: "estimation must be a Fibonacci number",
|
|
17
|
+
})
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Check if a number is a perfect square
|
|
21
|
+
const isPerfectSquare = (num: number): boolean => {
|
|
22
|
+
if (num < 0) return false
|
|
23
|
+
const sqrt = Math.sqrt(num)
|
|
24
|
+
return sqrt === Math.floor(sqrt)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// n is Fibonacci iff 5n²+4 or 5n²-4 is a perfect square
|
|
28
|
+
const fiveSqPlusFour = 5 * n * n + 4
|
|
29
|
+
const fiveSqMinusFour = 5 * n * n - 4
|
|
30
|
+
|
|
31
|
+
if (isPerfectSquare(fiveSqPlusFour) || isPerfectSquare(fiveSqMinusFour)) {
|
|
32
|
+
return Effect.succeed(void 0)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return Effect.fail({
|
|
36
|
+
_tag: "validation_error",
|
|
37
|
+
message: "estimation must be a Fibonacci number",
|
|
38
|
+
})
|
|
39
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { Effect } from "effect"
|
|
2
|
+
import type { TaskError } from "./types.js"
|
|
3
|
+
|
|
4
|
+
export interface KTokensConfig {
|
|
5
|
+
anchorPoint: number // Fibonacci number to anchor against
|
|
6
|
+
kTokensAtAnchor: number // how many kTokens map to anchorPoint
|
|
7
|
+
maxKTokens: number // cap (inclusive)
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const defaultConfig: KTokensConfig = {
|
|
11
|
+
anchorPoint: 8,
|
|
12
|
+
kTokensAtAnchor: 20,
|
|
13
|
+
maxKTokens: 20,
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Fibonacci sequence for lookup
|
|
17
|
+
const FIBONACCI = [1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144]
|
|
18
|
+
|
|
19
|
+
export const estimateFromKTokens = (
|
|
20
|
+
kTokens: number,
|
|
21
|
+
config: KTokensConfig = defaultConfig
|
|
22
|
+
): Effect.Effect<number, TaskError> => {
|
|
23
|
+
// Fail if kTokens <= 0
|
|
24
|
+
if (kTokens <= 0) {
|
|
25
|
+
return Effect.fail({
|
|
26
|
+
_tag: "validation_error",
|
|
27
|
+
message: "predicted kilotokens must be positive",
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Fail if kTokens > config.maxKTokens
|
|
32
|
+
if (kTokens > config.maxKTokens) {
|
|
33
|
+
return Effect.fail({
|
|
34
|
+
_tag: "validation_error",
|
|
35
|
+
message: "predicted kilotokens exceed maximum allowed",
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Calculate ratio: kTokensAtAnchor / anchorPoint
|
|
40
|
+
const ratio = config.kTokensAtAnchor / config.anchorPoint
|
|
41
|
+
|
|
42
|
+
// Scale kTokens to estimate space
|
|
43
|
+
const scaled = kTokens / ratio
|
|
44
|
+
|
|
45
|
+
// Find nearest Fibonacci number, rounding UP on tie
|
|
46
|
+
let nearestFib: number = FIBONACCI[0] ?? 1
|
|
47
|
+
let minDistance = Math.abs(nearestFib - scaled)
|
|
48
|
+
|
|
49
|
+
for (const fib of FIBONACCI) {
|
|
50
|
+
const distance = Math.abs(fib - scaled)
|
|
51
|
+
|
|
52
|
+
// On exact match, return immediately
|
|
53
|
+
if (distance === 0) {
|
|
54
|
+
return Effect.succeed(fib)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// On tie, pick the larger value (UP)
|
|
58
|
+
if (distance < minDistance || (distance === minDistance && fib > nearestFib)) {
|
|
59
|
+
nearestFib = fib
|
|
60
|
+
minDistance = distance
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return Effect.succeed(nearestFib)
|
|
65
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Effect } from "effect"
|
|
2
|
+
import type { Status, TaskError } from "./types.js"
|
|
3
|
+
|
|
4
|
+
// Allowed state transitions: from → [to, to, ...]
|
|
5
|
+
const allowedTransitions: Record<Status, Status[]> = {
|
|
6
|
+
backlog: ["todo"],
|
|
7
|
+
todo: ["backlog", "in_progress"],
|
|
8
|
+
in_progress: ["todo", "pending_review", "need_info", "blocked"],
|
|
9
|
+
blocked: ["in_progress"],
|
|
10
|
+
need_info: ["in_progress"],
|
|
11
|
+
pending_review: ["done", "in_progress"],
|
|
12
|
+
done: [],
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Guards a status transition, returning Effect.succeed(void) when allowed
|
|
17
|
+
* and Effect.fail({ _tag: 'transition_not_allowed', from, to }) otherwise.
|
|
18
|
+
* A same→same transition is always a no-op success.
|
|
19
|
+
*/
|
|
20
|
+
export const guardTransition = (from: Status, to: Status): Effect.Effect<void, TaskError> => {
|
|
21
|
+
// Same→same is always a no-op success
|
|
22
|
+
if (from === to) {
|
|
23
|
+
return Effect.void
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Check if transition is in the allowed map
|
|
27
|
+
const allowed = allowedTransitions[from]
|
|
28
|
+
if (allowed.includes(to)) {
|
|
29
|
+
return Effect.void
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Transition not allowed
|
|
33
|
+
return Effect.fail({
|
|
34
|
+
_tag: "transition_not_allowed",
|
|
35
|
+
from,
|
|
36
|
+
to,
|
|
37
|
+
} as TaskError)
|
|
38
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { z } from "zod"
|
|
2
|
+
|
|
3
|
+
export const StatusSchema = z.enum([
|
|
4
|
+
"backlog",
|
|
5
|
+
"todo",
|
|
6
|
+
"need_info",
|
|
7
|
+
"blocked",
|
|
8
|
+
"in_progress",
|
|
9
|
+
"pending_review",
|
|
10
|
+
"done",
|
|
11
|
+
])
|
|
12
|
+
export type Status = z.infer<typeof StatusSchema>
|
|
13
|
+
|
|
14
|
+
export const CommentKindSchema = z.enum(["need_info", "regular"])
|
|
15
|
+
export type CommentKind = z.infer<typeof CommentKindSchema>
|
|
16
|
+
|
|
17
|
+
export const CommentSchema = z.object({
|
|
18
|
+
id: z.string().min(1),
|
|
19
|
+
timestamp: z.coerce.date(),
|
|
20
|
+
title: z.string().min(1),
|
|
21
|
+
content: z.string(),
|
|
22
|
+
reply: z.string(),
|
|
23
|
+
kind: CommentKindSchema,
|
|
24
|
+
})
|
|
25
|
+
export type Comment = z.infer<typeof CommentSchema>
|
|
26
|
+
|
|
27
|
+
export const AgentSchema = z.object({
|
|
28
|
+
id: z.string().min(1),
|
|
29
|
+
title: z.string().min(1),
|
|
30
|
+
description: z.string(),
|
|
31
|
+
})
|
|
32
|
+
export type Agent = z.infer<typeof AgentSchema>
|
|
33
|
+
|
|
34
|
+
export const TaskSchema = z.object({
|
|
35
|
+
project: z.string().min(1),
|
|
36
|
+
milestone: z.string().min(1),
|
|
37
|
+
id: z.string().min(1),
|
|
38
|
+
title: z.string().min(1),
|
|
39
|
+
definition_of_done: z.string().min(1),
|
|
40
|
+
description: z.string().min(1),
|
|
41
|
+
estimation: z.number().int().positive(),
|
|
42
|
+
comments: z.array(CommentSchema),
|
|
43
|
+
assignee: AgentSchema,
|
|
44
|
+
status: StatusSchema,
|
|
45
|
+
in_progress_since: z.coerce.date().optional(),
|
|
46
|
+
})
|
|
47
|
+
export type Task = z.infer<typeof TaskSchema>
|
|
48
|
+
|
|
49
|
+
// Error tags match Gherkin feature file error names
|
|
50
|
+
export type TaskError =
|
|
51
|
+
| { readonly _tag: "not_found"; readonly taskId: string }
|
|
52
|
+
| { readonly _tag: "transition_not_allowed"; readonly from: Status; readonly to: Status }
|
|
53
|
+
| { readonly _tag: "validation_error"; readonly message: string }
|
|
54
|
+
| { readonly _tag: "missing_comment" }
|
|
55
|
+
| { readonly _tag: "conflict"; readonly taskId: string }
|
|
56
|
+
| { readonly _tag: "no_current_task" }
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { Effect } from "effect"
|
|
2
|
+
import type { HookEvent } from "./ports.js"
|
|
3
|
+
|
|
4
|
+
export interface HookConfig {
|
|
5
|
+
event: string
|
|
6
|
+
condition?: string
|
|
7
|
+
timeout_ms?: number
|
|
8
|
+
script: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const HOOK_EVENT_NAME = "task.status_changed"
|
|
12
|
+
const DEFAULT_TIMEOUT_MS = 5000
|
|
13
|
+
|
|
14
|
+
const evaluateCondition = (condition: string, event: HookEvent): boolean => {
|
|
15
|
+
try {
|
|
16
|
+
const fn = new Function(
|
|
17
|
+
"new_status",
|
|
18
|
+
"old_status",
|
|
19
|
+
"task_id",
|
|
20
|
+
"session_id",
|
|
21
|
+
`return (${condition})`
|
|
22
|
+
)
|
|
23
|
+
return Boolean(fn(event.new_status, event.old_status, event.task_id, event.session_id))
|
|
24
|
+
} catch {
|
|
25
|
+
return false
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const runScript = (config: HookConfig, event: HookEvent): Promise<void> =>
|
|
30
|
+
new Promise((resolve) => {
|
|
31
|
+
const timeoutMs = config.timeout_ms ?? DEFAULT_TIMEOUT_MS
|
|
32
|
+
|
|
33
|
+
const cmd = config.script.endsWith(".ts") ? ["bun", config.script] : ["sh", "-c", config.script]
|
|
34
|
+
|
|
35
|
+
const child = Bun.spawn(cmd, {
|
|
36
|
+
env: {
|
|
37
|
+
...process.env,
|
|
38
|
+
LOGBOOK_TASK_ID: event.task_id,
|
|
39
|
+
LOGBOOK_OLD_STATUS: event.old_status,
|
|
40
|
+
LOGBOOK_NEW_STATUS: event.new_status,
|
|
41
|
+
LOGBOOK_SESSION_ID: event.session_id,
|
|
42
|
+
},
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
const timer = setTimeout(() => {
|
|
46
|
+
child.kill()
|
|
47
|
+
}, timeoutMs)
|
|
48
|
+
|
|
49
|
+
void (async () => {
|
|
50
|
+
try {
|
|
51
|
+
await child.exited
|
|
52
|
+
} finally {
|
|
53
|
+
clearTimeout(timer)
|
|
54
|
+
resolve()
|
|
55
|
+
}
|
|
56
|
+
})()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
export const executeHooks = (
|
|
60
|
+
event: HookEvent,
|
|
61
|
+
configs: readonly HookConfig[]
|
|
62
|
+
): Effect.Effect<void, never> =>
|
|
63
|
+
Effect.promise(async () => {
|
|
64
|
+
await Promise.all(
|
|
65
|
+
configs
|
|
66
|
+
.filter((c) => c.event === HOOK_EVENT_NAME)
|
|
67
|
+
.filter((c) => !c.condition || evaluateCondition(c.condition, event))
|
|
68
|
+
.map((c) => runScript(c, event))
|
|
69
|
+
)
|
|
70
|
+
})
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Context, type Effect } from "effect"
|
|
2
|
+
import type { Comment, Status } from "../domain/types.js"
|
|
3
|
+
|
|
4
|
+
export interface HookEvent {
|
|
5
|
+
task_id: string
|
|
6
|
+
old_status: Status
|
|
7
|
+
new_status: Status
|
|
8
|
+
comment: Comment | null
|
|
9
|
+
session_id: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface HookRunner {
|
|
13
|
+
run(event: HookEvent): Effect.Effect<void, never>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const HookRunner = Context.GenericTag<HookRunner>("HookRunner")
|