@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,40 @@
1
+ import { Effect, Either, 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
+ priority: z.number().int().min(0).optional(),
14
+ })
15
+
16
+ export const toolEditTask = (
17
+ rawInput: unknown,
18
+ layer: Layer.Layer<TaskRepository>
19
+ ): Promise<{ task: unknown }> => {
20
+ const parsed = InputSchema.parse(rawInput)
21
+ const { id } = parsed
22
+ // Build updates by omitting undefined fields (exact optional property types compliance)
23
+ const updates: EditTaskInput = {}
24
+ if (parsed.title !== undefined) updates.title = parsed.title
25
+ if (parsed.description !== undefined) updates.description = parsed.description
26
+ if (parsed.definition_of_done !== undefined)
27
+ updates.definition_of_done = parsed.definition_of_done
28
+ if (parsed.predictedKTokens !== undefined) updates.predictedKTokens = parsed.predictedKTokens
29
+ if (parsed.priority !== undefined) updates.priority = parsed.priority
30
+
31
+ return Effect.runPromise(
32
+ Effect.provide(
33
+ Effect.either(editTask(id, updates).pipe(Effect.map((task) => ({ task })))),
34
+ layer
35
+ )
36
+ ).then((either) => {
37
+ if (Either.isLeft(either)) throw either.left
38
+ return either.right
39
+ })
40
+ }
@@ -0,0 +1,34 @@
1
+ import { Effect, Either, 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
+ project: z.string().optional(),
10
+ milestone: z.string().optional(),
11
+ })
12
+
13
+ export const toolListTasks = (
14
+ rawInput: unknown,
15
+ layer: Layer.Layer<TaskRepository>
16
+ ): Promise<{ tasks: unknown[] }> => {
17
+ const input = InputSchema.parse(rawInput)
18
+ const options = {
19
+ status: input.status,
20
+ ...(input.project !== undefined ? { project: input.project } : {}),
21
+ ...(input.milestone !== undefined ? { milestone: input.milestone } : {}),
22
+ }
23
+ return Effect.runPromise(
24
+ Effect.provide(
25
+ Effect.either(
26
+ listTasks(options).pipe(Effect.map((tasks) => ({ tasks: tasks as unknown[] })))
27
+ ),
28
+ layer
29
+ )
30
+ ).then((either) => {
31
+ if (Either.isLeft(either)) throw either.left
32
+ return either.right
33
+ })
34
+ }
@@ -0,0 +1,55 @@
1
+ import { Effect, Either, 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 type { SessionRegistry } from "../task/session-registry.js"
7
+ import { updateTask } from "../task/update-task.js"
8
+
9
+ const CommentInputSchema = z
10
+ .object({
11
+ id: z.string().uuid().optional(), // provided only when replying to an existing comment
12
+ title: z.string().min(1),
13
+ content: z.string(),
14
+ reply: z.string().optional(), // reply text; only meaningful when id is provided
15
+ kind: CommentKindSchema,
16
+ })
17
+ .optional()
18
+
19
+ const InputSchema = z.object({
20
+ id: z.string().min(1),
21
+ new_status: StatusSchema,
22
+ comment: CommentInputSchema,
23
+ })
24
+
25
+ export const toolUpdateTask = (
26
+ rawInput: unknown,
27
+ sessionId: string,
28
+ layer: Layer.Layer<TaskRepository | HookRunner | SessionRegistry>
29
+ ): Promise<{ ok: boolean }> => {
30
+ const input = InputSchema.parse(rawInput)
31
+ const comment = input.comment
32
+ ? {
33
+ id: input.comment.id ?? crypto.randomUUID(),
34
+ timestamp: new Date(),
35
+ title: input.comment.title,
36
+ content: input.comment.content,
37
+ reply: input.comment.reply ?? "",
38
+ kind: input.comment.kind,
39
+ }
40
+ : null
41
+
42
+ return Effect.runPromise(
43
+ Effect.provide(
44
+ Effect.either(
45
+ updateTask(input.id, input.new_status, comment, sessionId).pipe(
46
+ Effect.map(() => ({ ok: true }))
47
+ )
48
+ ),
49
+ layer
50
+ )
51
+ ).then((either) => {
52
+ if (Either.isLeft(either)) throw either.left
53
+ return either.right
54
+ })
55
+ }
@@ -0,0 +1,67 @@
1
+ import { Effect } from "effect"
2
+ import { estimateFromKTokens } from "../domain/kTokens.js"
3
+ import type { Task, TaskError } from "../domain/types.js"
4
+ import { TaskRepository } from "./ports.js"
5
+
6
+ export interface CreateTaskInput {
7
+ project: string
8
+ milestone: string
9
+ title: string
10
+ definition_of_done: string
11
+ description: string
12
+ predictedKTokens: number
13
+ priority?: number
14
+ }
15
+
16
+ /**
17
+ * Creates a new task in `backlog` status with no assignee.
18
+ * Validates all fields and derives a Fibonacci estimation from predictedKTokens.
19
+ */
20
+ export const createTask = (
21
+ input: CreateTaskInput
22
+ ): Effect.Effect<Task, TaskError, TaskRepository> => {
23
+ // Validate required string fields
24
+ const requiredStringFields: Array<keyof CreateTaskInput> = [
25
+ "project",
26
+ "milestone",
27
+ "title",
28
+ "definition_of_done",
29
+ "description",
30
+ ]
31
+
32
+ for (const field of requiredStringFields) {
33
+ if (typeof input[field] !== "string" || input[field] === "") {
34
+ return Effect.fail({
35
+ _tag: "validation_error" as const,
36
+ message: `${field} is required`,
37
+ })
38
+ }
39
+ }
40
+
41
+ // Validate predictedKTokens is defined and a number
42
+ if (input.predictedKTokens === undefined || input.predictedKTokens === null) {
43
+ return Effect.fail({
44
+ _tag: "validation_error" as const,
45
+ message: "predictedKTokens is required",
46
+ })
47
+ }
48
+
49
+ // Derive Fibonacci estimation from kTokens
50
+ return Effect.flatMap(estimateFromKTokens(input.predictedKTokens), (estimation) => {
51
+ const id = crypto.randomUUID()
52
+ const task: Task = {
53
+ project: input.project,
54
+ milestone: input.milestone,
55
+ id,
56
+ title: input.title,
57
+ definition_of_done: input.definition_of_done,
58
+ description: input.description,
59
+ estimation,
60
+ comments: [],
61
+ status: "backlog" as const,
62
+ priority: input.priority ?? 0,
63
+ }
64
+
65
+ return Effect.flatMap(TaskRepository, (repo) => repo.save(task)).pipe(Effect.map(() => task))
66
+ })
67
+ }
@@ -0,0 +1,111 @@
1
+ import { Effect } from "effect"
2
+ import type { Agent, Task, TaskError } from "../domain/types.js"
3
+ import type { TaskRepository as TaskRepositoryType } from "./ports.js"
4
+ import { TaskRepository } from "./ports.js"
5
+ import { SessionRegistry } from "./session-registry.js"
6
+
7
+ /**
8
+ * Returns the best available task for the given session, claiming it if needed.
9
+ *
10
+ * Priority chain:
11
+ * 1. Own in_progress — already assigned to this session → return highest-priority
12
+ * 2. Unassigned in_progress — no assignee → claim highest-priority, return
13
+ * 3. Orphaned in_progress — dead-session assignee → claim highest-priority, return
14
+ * 4. Highest-priority todo — auto-transition to in_progress, claim, return
15
+ * 5. Nothing → fail `no_current_task`
16
+ *
17
+ * Ties in priority are broken by in_progress_since ASC (oldest first).
18
+ * Claiming is a direct repo.update — no hooks, no HookRunner dependency.
19
+ */
20
+ export const currentTask = (
21
+ sessionId: string
22
+ ): Effect.Effect<Task, TaskError, TaskRepository | SessionRegistry> =>
23
+ Effect.flatMap(TaskRepository, (repo) =>
24
+ Effect.flatMap(repo.findByStatus("in_progress"), (inProgress) => {
25
+ const own = inProgress.filter((t) => t.assignee?.id === sessionId)
26
+ if (own.length > 0) return Effect.succeed(pickHighestPriority(own))
27
+
28
+ return stepUnassigned(inProgress, sessionId, repo).pipe(
29
+ Effect.catchTag("no_current_task", () =>
30
+ stepOrphan(sessionId, inProgress, repo).pipe(
31
+ Effect.catchTag("no_current_task", () => stepTodo(sessionId, repo))
32
+ )
33
+ )
34
+ )
35
+ })
36
+ )
37
+
38
+ const pickHighestPriority = <T extends Task>(tasks: readonly T[]): T => {
39
+ const sorted = [...tasks].sort((a, b) => {
40
+ if (b.priority !== a.priority) return b.priority - a.priority
41
+ const aTime = a.in_progress_since?.getTime() ?? Infinity
42
+ const bTime = b.in_progress_since?.getTime() ?? Infinity
43
+ return aTime - bTime
44
+ })
45
+ // biome-ignore lint/style/noNonNullAssertion: caller guarantees non-empty
46
+ return sorted[0]!
47
+ }
48
+
49
+ const claimTask = (
50
+ task: Task,
51
+ newAssignee: Agent,
52
+ repo: TaskRepositoryType
53
+ ): Effect.Effect<Task, TaskError, never> => {
54
+ const claimed = { ...task, assignee: newAssignee }
55
+ return Effect.map(repo.update(claimed), () => claimed)
56
+ }
57
+
58
+ const stepUnassigned = (
59
+ inProgress: readonly Task[],
60
+ sessionId: string,
61
+ repo: TaskRepositoryType
62
+ ): Effect.Effect<Task, TaskError, never> => {
63
+ const unassigned = inProgress.filter((t) => t.assignee === undefined)
64
+ if (unassigned.length === 0) return Effect.fail({ _tag: "no_current_task" as const })
65
+ const oldest = pickHighestPriority(unassigned)
66
+ return claimTask(oldest, { id: sessionId, title: "Agent", description: "" }, repo)
67
+ }
68
+
69
+ const stepOrphan = (
70
+ sessionId: string,
71
+ candidates: readonly Task[],
72
+ repo: TaskRepositoryType
73
+ ): Effect.Effect<Task, TaskError, SessionRegistry> => {
74
+ const foreign = candidates.filter(
75
+ (t): t is Task & { assignee: Agent } => t.assignee !== undefined && t.assignee.id !== sessionId
76
+ )
77
+ if (foreign.length === 0) return Effect.fail({ _tag: "no_current_task" as const })
78
+ return Effect.flatMap(SessionRegistry, (registry) =>
79
+ Effect.flatMap(
80
+ Effect.forEach(foreign, (t) =>
81
+ registry.isAlive(t.assignee.id).pipe(Effect.map((alive) => ({ task: t, alive })))
82
+ ),
83
+ (results) => {
84
+ const orphans = results.filter((r) => !r.alive).map((r) => r.task)
85
+ if (orphans.length === 0) return Effect.fail({ _tag: "no_current_task" as const })
86
+ const oldest = pickHighestPriority(orphans)
87
+ return claimTask(oldest, { ...oldest.assignee, id: sessionId }, repo)
88
+ }
89
+ )
90
+ )
91
+ }
92
+
93
+ const stepTodo = (
94
+ sessionId: string,
95
+ repo: TaskRepositoryType
96
+ ): Effect.Effect<Task, TaskError, never> =>
97
+ Effect.flatMap(repo.findByStatus("todo"), (todos) => {
98
+ if (todos.length === 0) return Effect.fail({ _tag: "no_current_task" as const })
99
+ // biome-ignore lint/style/noNonNullAssertion: length guard above
100
+ const best = [...todos].sort((a, b) => b.priority - a.priority)[0]!
101
+ const claimed: Task = {
102
+ ...best,
103
+ status: "in_progress",
104
+ assignee: {
105
+ ...(best.assignee ?? { title: "Agent", description: "" }),
106
+ id: sessionId,
107
+ },
108
+ in_progress_since: new Date(),
109
+ }
110
+ return Effect.map(repo.update(claimed), () => claimed)
111
+ })
@@ -0,0 +1,59 @@
1
+ import { Effect } from "effect"
2
+ import { estimateFromKTokens } from "../domain/kTokens.js"
3
+ import type { Task, TaskError } from "../domain/types.js"
4
+ import { TaskRepository } from "./ports.js"
5
+
6
+ export interface EditTaskInput {
7
+ title?: string
8
+ description?: string
9
+ definition_of_done?: string
10
+ predictedKTokens?: number
11
+ priority?: number
12
+ }
13
+
14
+ /**
15
+ * Edits mutable fields of an existing task without changing its status.
16
+ * Derives Fibonacci estimation from predictedKTokens when provided.
17
+ * Fails with `not_found` for unknown id.
18
+ * Fails with `validation_error` when a `status` field is attempted via EditTaskInput.
19
+ */
20
+ export const editTask = (
21
+ id: string,
22
+ updates: EditTaskInput
23
+ ): Effect.Effect<Task, TaskError, TaskRepository> => {
24
+ // Check for attempted status modification (runtime guard against type system bypass)
25
+ if ("status" in updates) {
26
+ return Effect.fail({
27
+ _tag: "validation_error" as const,
28
+ message: "status field cannot be edited",
29
+ })
30
+ }
31
+
32
+ // Derive estimation from predictedKTokens if present
33
+ if (updates.predictedKTokens !== undefined) {
34
+ return Effect.flatMap(estimateFromKTokens(updates.predictedKTokens), (estimation) =>
35
+ Effect.flatMap(TaskRepository, (repo) =>
36
+ Effect.flatMap(repo.findById(id), (task) => {
37
+ const { predictedKTokens: _, ...rest } = updates
38
+ const updatedTask: Task = {
39
+ ...task,
40
+ ...rest,
41
+ estimation,
42
+ }
43
+ return Effect.flatMap(repo.update(updatedTask), () => Effect.succeed(updatedTask))
44
+ })
45
+ )
46
+ )
47
+ }
48
+
49
+ // No estimation to derive, proceed directly with find and update
50
+ return Effect.flatMap(TaskRepository, (repo) =>
51
+ Effect.flatMap(repo.findById(id), (task) => {
52
+ const updatedTask: Task = {
53
+ ...task,
54
+ ...updates,
55
+ }
56
+ return Effect.flatMap(repo.update(updatedTask), () => Effect.succeed(updatedTask))
57
+ })
58
+ )
59
+ }
@@ -0,0 +1,35 @@
1
+ import { Effect } from "effect"
2
+ import type { Status, Task, TaskError } from "../domain/types.js"
3
+ import { TaskRepository } from "./ports.js"
4
+
5
+ type ListTasksOptions = {
6
+ status: Status | "*"
7
+ project?: string
8
+ milestone?: string
9
+ }
10
+
11
+ const applyFilters = (tasks: readonly Task[], options: ListTasksOptions): readonly Task[] => {
12
+ let result = tasks
13
+ if (options.project !== undefined) {
14
+ result = result.filter((t) => t.project === options.project)
15
+ }
16
+ if (options.milestone !== undefined) {
17
+ result = result.filter((t) => t.milestone === options.milestone)
18
+ }
19
+ return result
20
+ }
21
+
22
+ /**
23
+ * Returns tasks matching the given status (and optional project/milestone), or all tasks when status is '*'.
24
+ * Filters compose: all provided filters must match.
25
+ * Results are ordered by priority DESC.
26
+ * Fails with `validation_error` when the underlying data is malformed.
27
+ */
28
+ export const listTasks = (
29
+ options: ListTasksOptions
30
+ ): Effect.Effect<readonly Task[], TaskError, TaskRepository> =>
31
+ Effect.flatMap(TaskRepository, (repo) =>
32
+ Effect.map(repo.findByStatus(options.status), (tasks) =>
33
+ [...applyFilters(tasks, options)].sort((a, b) => b.priority - a.priority)
34
+ )
35
+ )
@@ -0,0 +1,15 @@
1
+ import { Context, type Effect } from "effect"
2
+ import type { Status, Task, TaskError } from "../domain/types.js"
3
+
4
+ export interface TaskRepository {
5
+ /** Fails with `not_found` if id is absent. */
6
+ findById(id: string): Effect.Effect<Task, TaskError>
7
+ /** Returns empty array when nothing matches; fails with `validation_error` on malformed data. */
8
+ findByStatus(status: Status | "*"): Effect.Effect<readonly Task[], TaskError>
9
+ /** Fails with `conflict` if a task with the same id already exists. */
10
+ save(task: Task): Effect.Effect<void, TaskError>
11
+ /** Fails with `not_found` if id is absent. */
12
+ update(task: Task): Effect.Effect<void, TaskError>
13
+ }
14
+
15
+ export const TaskRepository = Context.GenericTag<TaskRepository>("TaskRepository")
@@ -0,0 +1,9 @@
1
+ import { Context, type Effect } from "effect"
2
+
3
+ export interface SessionRegistry {
4
+ isAlive(sessionId: string): Effect.Effect<boolean, never>
5
+ register(sessionId: string, pid: number): Effect.Effect<void, never>
6
+ deregister(sessionId: string): Effect.Effect<void, never>
7
+ }
8
+
9
+ export const SessionRegistry = Context.GenericTag<SessionRegistry>("SessionRegistry")
@@ -0,0 +1,160 @@
1
+ import { Effect } from "effect"
2
+ import { guardTransition } from "../domain/status-machine.js"
3
+ import type { Comment, Status, TaskError } from "../domain/types.js"
4
+ import { HookRunner } from "../hook/ports.js"
5
+ import { TaskRepository } from "./ports.js"
6
+ import { SessionRegistry } from "./session-registry.js"
7
+
8
+ /**
9
+ * Transitions a task to a new status, optionally attaching or replying to a comment.
10
+ * Enforces transition rules, comment requirements, need_info reply cycle,
11
+ * and concurrent in_progress justification.
12
+ * Fires HookRunner after a successful status change.
13
+ */
14
+ export const updateTask = (
15
+ id: string,
16
+ newStatus: Status,
17
+ comment: Comment | null,
18
+ sessionId: string
19
+ ): Effect.Effect<void, TaskError, TaskRepository | HookRunner | SessionRegistry> =>
20
+ Effect.gen(function* () {
21
+ const repo = yield* TaskRepository
22
+ const hookRunner = yield* HookRunner
23
+
24
+ // Step 1: find task or fail with not_found
25
+ const task = yield* repo.findById(id)
26
+
27
+ // Step 2: guard transition (same→same is allowed by guardTransition too)
28
+ // Pass task id so review tasks can skip pending_review and go directly to done
29
+ yield* guardTransition(task.status, newStatus, task.id)
30
+
31
+ // Step 4: reply handling — comment id matches an existing comment
32
+ // Must run before the no-op check because a reply update is meaningful
33
+ // even when the status is not changing.
34
+ if (comment !== null) {
35
+ const existing = task.comments.find((c) => c.id === comment.id)
36
+ if (existing !== undefined) {
37
+ if (existing.kind === "regular") {
38
+ return yield* Effect.fail<TaskError>({
39
+ _tag: "validation_error",
40
+ message: "reply is only valid on need_info comments",
41
+ context: { commentId: existing.id, commentKind: existing.kind },
42
+ })
43
+ }
44
+ // existing.kind === 'need_info': merge reply and persist, no hook, no status change
45
+ const updatedComments = task.comments.map((c) =>
46
+ c.id === comment.id ? { ...c, reply: comment.reply } : c
47
+ )
48
+ const updatedTask = { ...task, comments: updatedComments }
49
+ yield* repo.update(updatedTask)
50
+ return
51
+ }
52
+ }
53
+
54
+ // Step 3: no-op when status unchanged (and no reply was handled above)
55
+ if (task.status === newStatus) return
56
+
57
+ // Step 5: need_info requires a comment
58
+ if (newStatus === "need_info" && comment === null) {
59
+ return yield* Effect.fail<TaskError>({
60
+ _tag: "missing_comment",
61
+ from: task.status,
62
+ to: newStatus,
63
+ })
64
+ }
65
+
66
+ // Step 6: blocked requires a non-empty comment
67
+ if (newStatus === "blocked") {
68
+ if (comment === null) {
69
+ return yield* Effect.fail<TaskError>({
70
+ _tag: "missing_comment",
71
+ from: task.status,
72
+ to: newStatus,
73
+ })
74
+ }
75
+ if (comment.content.trim() === "") {
76
+ return yield* Effect.fail<TaskError>({
77
+ _tag: "validation_error",
78
+ message: "blocked requires a non-empty comment",
79
+ context: { from: task.status, to: newStatus },
80
+ })
81
+ }
82
+ }
83
+
84
+ // Step 7: transitioning FROM need_info — all need_info comments must have a reply
85
+ if (task.status === "need_info") {
86
+ const blocking = task.comments.find((c) => c.kind === "need_info" && c.reply === "")
87
+ if (blocking !== undefined) {
88
+ return yield* Effect.fail<TaskError>({
89
+ _tag: "validation_error",
90
+ message: `blocking comment ${blocking.id} has no reply`,
91
+ context: {
92
+ commentId: blocking.id,
93
+ commentTitle: blocking.title,
94
+ commentContent: blocking.content,
95
+ commentTimestamp: blocking.timestamp,
96
+ },
97
+ })
98
+ }
99
+ }
100
+
101
+ // Step 8b: ownership check — reject if a live foreign session owns this task
102
+ if (
103
+ newStatus === "in_progress" &&
104
+ task.assignee !== undefined &&
105
+ task.assignee.id !== sessionId
106
+ ) {
107
+ const registry = yield* SessionRegistry
108
+ const alive = yield* registry.isAlive(task.assignee.id)
109
+ if (alive) {
110
+ return yield* Effect.fail<TaskError>({
111
+ _tag: "validation_error",
112
+ message: `task is owned by an active session (${task.assignee.id})`,
113
+ context: { task },
114
+ })
115
+ }
116
+ // dead session — fall through and let step 9 reassign
117
+ }
118
+
119
+ // Step 8: concurrent in_progress — second task for same session requires justification
120
+ if (newStatus === "in_progress") {
121
+ const inProgressTasks = yield* repo.findByStatus("in_progress")
122
+ const sessionInProgress = inProgressTasks.filter(
123
+ (t) => t.assignee?.id === sessionId && t.id !== task.id
124
+ )
125
+ if (sessionInProgress.length > 0) {
126
+ if (comment === null || comment.content.trim() === "") {
127
+ return yield* Effect.fail<TaskError>({
128
+ _tag: "validation_error",
129
+ message: "moving a second task to in_progress requires a justification comment",
130
+ context: {
131
+ inProgressTasks: sessionInProgress.map((t) => ({ id: t.id, title: t.title })),
132
+ },
133
+ })
134
+ }
135
+ }
136
+ }
137
+
138
+ // Step 9: apply changes
139
+ const oldStatus = task.status
140
+ const updatedComments = comment !== null ? [...task.comments, comment] : task.comments
141
+ const updatedTask = {
142
+ ...task,
143
+ status: newStatus,
144
+ comments: updatedComments,
145
+ ...(newStatus === "in_progress"
146
+ ? {
147
+ in_progress_since: new Date(),
148
+ assignee: { id: sessionId, title: "Agent", description: "" },
149
+ }
150
+ : {}),
151
+ }
152
+ yield* repo.update(updatedTask)
153
+ yield* hookRunner.run({
154
+ task_id: id,
155
+ old_status: oldStatus,
156
+ new_status: newStatus,
157
+ comment,
158
+ session_id: sessionId,
159
+ })
160
+ })