@goodandready/dsh-kanban 0.2.34 → 0.2.35

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,345 @@
1
+ /**
2
+ * Extended actions for task route handlers (comments, reports, checklist, triage, subtasks, PR, nudge).
3
+ * Zero Cyrillic in lib/ according to Issue #366.
4
+ */
5
+
6
+ export function handleTaskChecklist({ store, task, input }) {
7
+ if (!task) return { status: 404, body: { error: 'task-not-found' } }
8
+ const list = Array.isArray(input?.checklist) ? input.checklist : Array.isArray(input) ? input : []
9
+ const updated = store.updateChecklist(task.id, list)
10
+ return { status: 200, body: { task: updated } }
11
+ }
12
+
13
+ /**
14
+ * Set completion report for task.
15
+ */
16
+ export function handleTaskReport({ store, task, input }) {
17
+ if (!task) return { status: 404, body: { error: 'task-not-found' } }
18
+ const updated = store.setReport(task.id, input)
19
+ return { status: 200, body: { task: updated } }
20
+ }
21
+
22
+ /**
23
+ * Get comments for task.
24
+ */
25
+ export function handleGetTaskComments({ task }) {
26
+ if (!task) return { status: 404, body: { error: 'task-not-found' } }
27
+ return { status: 200, body: { comments: task.comments || [] } }
28
+ }
29
+
30
+ /**
31
+ * Add comment to task.
32
+ */
33
+ export function handleAddTaskComment({ store, task, input }) {
34
+ if (!task) return { status: 404, body: { error: 'task-not-found' } }
35
+ const author = input?.author || 'user'
36
+ const role = input?.role || 'user'
37
+ const text = input?.text || ''
38
+ const updated = store.addComment(task.id, { author, role, text })
39
+ return { status: 200, body: { task: updated } }
40
+ }
41
+
42
+ /**
43
+ * Accept task review (transition to done).
44
+ */
45
+ export function handleAcceptTask({ store, task }) {
46
+ if (!task) return { status: 404, body: { error: 'task-not-found' } }
47
+ const updated = store.acceptTask(task.id)
48
+ return { status: 200, body: { task: updated } }
49
+ }
50
+
51
+ /**
52
+ * Reject task review (return to in_progress).
53
+ */
54
+ export function handleRejectTask({ store, task, input }) {
55
+ if (!task) return { status: 404, body: { error: 'task-not-found' } }
56
+ const reason = String(input?.reason || '').trim()
57
+ if (!reason) return { status: 400, body: { error: 'reason-required' } }
58
+ const updated = store.rejectTask(task.id, reason)
59
+ return { status: 200, body: { task: updated } }
60
+ }
61
+
62
+ /**
63
+ * Save task progress dump snapshot.
64
+ */
65
+ export function handleProgressDump({ store, task, input, formatProgressDump }) {
66
+ if (!task) return { status: 404, body: { error: 'task-not-found' } }
67
+ const formatted = typeof input === 'string' ? input : formatProgressDump(input)
68
+ const updated = store.updateTask(task.id, { progressDump: formatted })
69
+ return { status: 200, body: { task: updated } }
70
+ }
71
+
72
+ /**
73
+ * Decompose a task into subtasks and make the parent an Epic.
74
+ */
75
+ export async function handleTriageTask({ store, gitea, task, input }) {
76
+ if (!task) return { status: 404, body: { error: 'task-not-found' } }
77
+ const subtasksInput = Array.isArray(input?.subtasks) ? input.subtasks : []
78
+ if (subtasksInput.length === 0) {
79
+ return { status: 400, body: { error: 'subtasks-required' } }
80
+ }
81
+ const createGitea = input?.createGiteaIssues === true && Boolean(task.owner && task.repo && gitea)
82
+
83
+ // Convert parent into Epic
84
+ store.updateTask(task.id, { isEpic: true })
85
+
86
+ const created = []
87
+ for (const item of subtasksInput) {
88
+ const title = String(item?.title ?? '').trim()
89
+ if (!title) continue
90
+ const body = String(item?.body ?? '').trim()
91
+ let issueNumber = null
92
+ let issueUrl = null
93
+
94
+ if (createGitea) {
95
+ try {
96
+ const issueBody = body ? `${body}\n\nParent epic: #${task.issueNumber || task.id}` : `Parent epic: #${task.issueNumber || task.id}`
97
+ const giteaIssue = await gitea.createIssue({
98
+ owner: task.owner,
99
+ repo: task.repo,
100
+ title,
101
+ body: issueBody,
102
+ })
103
+ if (giteaIssue?.number) {
104
+ issueNumber = giteaIssue.number
105
+ issueUrl = giteaIssue.html_url || null
106
+ }
107
+ } catch { /* ignore gitea subtask creation failure */ }
108
+ }
109
+
110
+ const sub = store.createTask({
111
+ board: task.board || 'main',
112
+ column: 'backlog',
113
+ title,
114
+ body,
115
+ parentId: task.id,
116
+ owner: task.owner || null,
117
+ repo: task.repo || null,
118
+ issueNumber,
119
+ issueUrl,
120
+ labels: Array.isArray(item?.labels) ? item.labels : [],
121
+ workspaceId: task.workspaceId || '',
122
+ })
123
+ created.push(sub)
124
+ }
125
+
126
+ store.addTransition({
127
+ taskId: task.id,
128
+ fromCol: task.column,
129
+ toCol: task.column,
130
+ source: 'manual',
131
+ detail: `decomposed into ${created.length} subtasks (epic triage)`,
132
+ })
133
+
134
+ return {
135
+ status: 200,
136
+ body: {
137
+ epic: store.getTask(task.id),
138
+ subtasks: store.listSubtasks(task.id),
139
+ counts: store.countSubtasks(task.id),
140
+ createdCount: created.length,
141
+ },
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Get subtasks of a task.
147
+ */
148
+ export function handleGetSubtasks({ store, task }) {
149
+ if (!task) return { status: 404, body: { error: 'task-not-found' } }
150
+ return {
151
+ status: 200,
152
+ body: {
153
+ subtasks: store.listSubtasks(task.id),
154
+ counts: store.countSubtasks(task.id),
155
+ },
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Create a single subtask linked to a parent task.
161
+ */
162
+ export async function handleCreateSubtask({ store, gitea, task, input }) {
163
+ if (!task) return { status: 404, body: { error: 'task-not-found' } }
164
+ const title = String(input?.title ?? '').trim()
165
+ if (!title) return { status: 400, body: { error: 'title-required' } }
166
+ const body = String(input?.body ?? '').trim()
167
+ let issueNumber = null
168
+ let issueUrl = null
169
+
170
+ if (input?.createGiteaIssue === true && task.owner && task.repo && gitea) {
171
+ try {
172
+ const issueBody = body ? `${body}\n\nParent epic: #${task.issueNumber || task.id}` : `Parent epic: #${task.issueNumber || task.id}`
173
+ const giteaIssue = await gitea.createIssue({
174
+ owner: task.owner,
175
+ repo: task.repo,
176
+ title,
177
+ body: issueBody,
178
+ })
179
+ if (giteaIssue?.number) {
180
+ issueNumber = giteaIssue.number
181
+ issueUrl = giteaIssue.html_url || null
182
+ }
183
+ } catch { /* ignore gitea subtask creation failure */ }
184
+ }
185
+
186
+ store.updateTask(task.id, { isEpic: true })
187
+ const sub = store.createTask({
188
+ board: task.board || 'main',
189
+ column: input?.column || 'backlog',
190
+ title,
191
+ body,
192
+ parentId: task.id,
193
+ owner: task.owner || null,
194
+ repo: task.repo || null,
195
+ issueNumber,
196
+ issueUrl,
197
+ labels: Array.isArray(input?.labels) ? input.labels : [],
198
+ workspaceId: task.workspaceId || '',
199
+ })
200
+ return { status: 200, body: { subtask: sub, counts: store.countSubtasks(task.id) } }
201
+ }
202
+
203
+ /**
204
+ * Toggle a checklist item atomically by index or text.
205
+ */
206
+ export function handleToggleChecklistItem({ store, task, input }) {
207
+ if (!task) return { status: 404, body: { error: 'task-not-found' } }
208
+ const current = Array.isArray(task.checklist) ? [...task.checklist] : []
209
+ let targetIndex = -1
210
+
211
+ if (typeof input?.index === 'number' && input.index >= 0 && input.index < current.length) {
212
+ targetIndex = input.index
213
+ } else if (typeof input?.text === 'string' && input.text.trim()) {
214
+ const q = input.text.trim().toLowerCase()
215
+ targetIndex = current.findIndex((item) => {
216
+ const t = typeof item === 'string' ? item : item?.text
217
+ return typeof t === 'string' && t.toLowerCase().includes(q)
218
+ })
219
+ }
220
+
221
+ if (targetIndex === -1) {
222
+ return { status: 404, body: { error: 'checklist-item-not-found' } }
223
+ }
224
+
225
+ const item = current[targetIndex]
226
+ let updatedItem
227
+ if (typeof item === 'string') {
228
+ const checked = input?.checked !== undefined ? Boolean(input.checked) : true
229
+ updatedItem = { text: item, completed: checked, done: checked }
230
+ } else {
231
+ const isCurrentlyDone = item.completed !== undefined ? Boolean(item.completed) : Boolean(item.done)
232
+ const checked = input?.checked !== undefined ? Boolean(input.checked) : !isCurrentlyDone
233
+ updatedItem = { ...item, completed: checked, done: checked }
234
+ }
235
+ current[targetIndex] = updatedItem
236
+ const updated = store.updateChecklist(task.id, current)
237
+ return { status: 200, body: { task: updated, item: updatedItem, index: targetIndex } }
238
+ }
239
+
240
+ /**
241
+ * Create a Gitea Pull Request for a task.
242
+ */
243
+ export async function handleCreateTaskPr({ store, gitea, task, input }) {
244
+ if (!task) return { status: 404, body: { error: 'task-not-found' } }
245
+ if (!task.owner || !task.repo) {
246
+ return { status: 400, body: { error: 'task-no-repo' } }
247
+ }
248
+ const head = String(input?.head || task.branch || '').trim()
249
+ if (!head) return { status: 400, body: { error: 'branch-required' } }
250
+ const base = String(input?.base || 'main').trim()
251
+ const title = String(input?.title || task.title || `PR: ${task.title}`).trim()
252
+ const body = String(input?.body || (task.issueNumber ? `Closes #${task.issueNumber}` : '')).trim()
253
+
254
+ try {
255
+ const pr = await gitea.createPullRequest({
256
+ owner: task.owner,
257
+ repo: task.repo,
258
+ title,
259
+ body,
260
+ head,
261
+ base,
262
+ })
263
+ store.addTransition({
264
+ taskId: task.id,
265
+ fromCol: task.column,
266
+ toCol: task.column,
267
+ source: 'manual',
268
+ detail: `Created PR #${pr?.number || ''}: ${head} -> ${base}`,
269
+ })
270
+ return { status: 200, body: { pr, prUrl: pr?.html_url } }
271
+ } catch (err) {
272
+ return { status: 502, body: { error: 'gitea-pr-failed', detail: err?.message } }
273
+ }
274
+ }
275
+ /**
276
+ * Handle interactive live steering (nudge) for an active task agent (#373).
277
+ *
278
+ * @param {{
279
+ * store: object,
280
+ * id: string,
281
+ * prompt: string,
282
+ * agents?: object,
283
+ * createMessage?: Function,
284
+ * eventHub?: object,
285
+ * now?: number
286
+ * }} options
287
+ * @returns {Promise<{ status: number, body: object }>}
288
+ */
289
+ export async function handleTaskNudge({ store, id, prompt, agents, createMessage, eventHub, now = Date.now() }) {
290
+ if (!store || !id) return { status: 400, body: { error: 'invalid-parameters' } }
291
+ const task = store.getTask(id)
292
+ if (!task) return { status: 404, body: { error: 'task-not-found' } }
293
+
294
+ const text = typeof prompt === 'string' ? prompt.trim() : ''
295
+ if (!text) return { status: 400, body: { error: 'prompt-required' } }
296
+
297
+ const sessionId = typeof task.sessionId === 'string' ? task.sessionId : ''
298
+ if (!sessionId) return { status: 409, body: { error: 'no-session' } }
299
+
300
+ let agent
301
+ try { agent = agents?.get?.(sessionId) } catch { agent = undefined }
302
+ if (!agent || typeof agent.followup !== 'function') {
303
+ return { status: 409, body: { error: 'session-not-live' } }
304
+ }
305
+
306
+ const msg = typeof createMessage === 'function'
307
+ ? createMessage({
308
+ content: [{ type: 'text', text }],
309
+ source: { kind: 'dsh-kanban', plugin: 'dsh-kanban', form: 'task-nudge' },
310
+ })
311
+ : { content: [{ type: 'text', text }] }
312
+
313
+ agent.followup(msg)
314
+
315
+ // Append user steering comment to task
316
+ if (typeof store.addComment === 'function') {
317
+ store.addComment(id, {
318
+ author: 'user',
319
+ role: 'user',
320
+ text: `[Steering Nudge] ${text}`,
321
+ })
322
+ } else if (typeof store.addTaskComment === 'function') {
323
+ store.addTaskComment(id, {
324
+ author: 'user',
325
+ role: 'user',
326
+ text: `[Steering Nudge] ${text}`,
327
+ })
328
+ }
329
+
330
+ // Broadcast change via eventHub
331
+ if (eventHub && typeof eventHub.broadcast === 'function') {
332
+ eventHub.broadcast('task:change', { id, col: task.col || task.column })
333
+ }
334
+
335
+ return {
336
+ status: 200,
337
+ body: {
338
+ success: true,
339
+ id,
340
+ sessionId,
341
+ prompt: text,
342
+ nudgedAt: now,
343
+ },
344
+ }
345
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Worktree actions for task route handlers.
3
+ * Zero Cyrillic in lib/ according to Issue #366.
4
+ */
5
+
6
+ export async function handleCleanupWorktree({
7
+ store, task, workspaceRegistry, force, cleanupTaskWorktree,
8
+ config, repoDir, cwdOf, env, gitRunner,
9
+ }) {
10
+ if (!task) return { status: 404, body: { error: 'task-not-found' } }
11
+ const out = await cleanupTaskWorktree({
12
+ store,
13
+ task,
14
+ workspaceRegistry,
15
+ force: force === true,
16
+ config,
17
+ repoDir,
18
+ cwdOf,
19
+ env,
20
+ gitRunner,
21
+ })
22
+ const status = out.status || (out.error ? (out.error === 'unfenced-worktree-path' ? 403 : 500) : 200)
23
+ return { status, body: out }
24
+ }
25
+
26
+ /**
27
+ * Get working copy diff for task.
28
+ */
29
+ export async function handleTaskDiff({ task, getTaskDiff, getTaskDiffInspector }) {
30
+ if (!task) return { status: 404, body: { error: 'task-not-found' } }
31
+ if (typeof getTaskDiffInspector === 'function') {
32
+ const inspectorResult = await getTaskDiffInspector({ worktreePath: task.worktree })
33
+ return { status: 200, body: inspectorResult }
34
+ }
35
+ const diffResult = await getTaskDiff({ worktreePath: task.worktree })
36
+ return { status: 200, body: diffResult }
37
+ }
38
+
39
+ /**
40
+ * Discard file changes in task worktree (#371).
41
+ */
42
+ export async function handleTaskDiscard({ task, file, discardTaskFile, gitRunner }) {
43
+ if (!task) return { status: 404, body: { error: 'task-not-found' } }
44
+ if (!file || typeof file !== 'string') return { status: 400, body: { error: 'file-required' } }
45
+ if (!task.worktree) return { status: 400, body: { error: 'no-worktree' } }
46
+ const result = await discardTaskFile({ worktreePath: task.worktree, file, gitRunner })
47
+ return { status: result.success ? 200 : 400, body: result }
48
+ }
49
+
50
+ /**
51
+ * Get commit list for task.
52
+ */
53
+ export async function handleTaskCommits({ task, getTaskCommits, gitRunner }) {
54
+ if (!task) return { status: 404, body: { error: 'task-not-found' } }
55
+ const commits = await getTaskCommits({ worktreePath: task.worktree, gitRunner })
56
+ return { status: 200, body: { commits } }
57
+ }
58
+
59
+ /**
60
+ * Merge task worktree branch and transition to done.
61
+ */
62
+ export async function handleTaskMerge({ store, task, targetBranch, workspaceRegistry, mergeTaskWorktree, gitRunner }) {
63
+ if (!task) return { status: 404, body: { error: 'task-not-found' } }
64
+ const mergeResult = await mergeTaskWorktree({
65
+ task,
66
+ targetBranch: typeof targetBranch === 'string' ? targetBranch.trim() : undefined,
67
+ workspaceRegistry,
68
+ gitRunner,
69
+ })
70
+ if (!mergeResult.success) {
71
+ return {
72
+ status: 400,
73
+ body: { error: mergeResult.error, detail: mergeResult.detail, files: mergeResult.files },
74
+ }
75
+ }
76
+ const updated = store.acceptTask(task.id)
77
+ store.addTransition({
78
+ taskId: task.id,
79
+ fromCol: task.column,
80
+ toCol: 'done',
81
+ source: 'manual',
82
+ detail: `accepted and merged branch ${task.branch} into ${mergeResult.targetBranch}`,
83
+ })
84
+ return { status: 200, body: { task: updated, merged: true, targetBranch: mergeResult.targetBranch } }
85
+ }
86
+
87
+ /**
88
+ * Check task worktree status (branch, dirty files, workspace).
89
+ */
90
+ export async function handleTaskWorktreeStatus({ task, checkWorktreeDirty }) {
91
+ if (!task) return { status: 404, body: { error: 'task-not-found' } }
92
+ const dirtyCheck = await checkWorktreeDirty({ worktreePath: task.worktree })
93
+ return {
94
+ status: 200,
95
+ body: {
96
+ worktree: task.worktree || '',
97
+ branch: task.branch || '',
98
+ workspaceId: task.workspaceId || '',
99
+ dirty: dirtyCheck.dirty,
100
+ files: dirtyCheck.files,
101
+ },
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Update Definition of Done checklist for task.
107
+ */
@@ -0,0 +1,211 @@
1
+ import { parseBody } from '../markdown.js'
2
+ import { columnsOf, wipLimitField } from '../config.js'
3
+ import { withPlan } from './board.js'
4
+
5
+ export function parseTaskPath(pathname) {
6
+ const m = /^\/dsh-kanban\/task\/([^/]+)(?:\/([a-z-]+))?\/?$/.exec(pathname ?? '')
7
+ if (m === null) return undefined
8
+ return { id: decodeURIComponent(m[1]), action: m[2] }
9
+ }
10
+
11
+ /**
12
+ * Enrich task with parsed plan, state, and filters.
13
+ *
14
+ * Plan is pre-parsed on server because client bundle cannot import `lib/plan.js`.
15
+ * `liveOf` returns active agent state or undefined if session is inactive.
16
+ */
17
+
18
+ export function applyMove({ store, config, id, column, beforeId, afterId, source = 'manual', detail = '' }) {
19
+ const before = store.getTask(id)
20
+ if (before === undefined) return { error: 'task-not-found', status: 404 }
21
+ if (column !== undefined && column !== null) {
22
+ const allowed = columnsOf(store.boardKind(before.board))
23
+ if (!allowed.includes(column)) {
24
+ return { error: 'invalid-column', status: 400 }
25
+ }
26
+ }
27
+ const task = store.moveTask(id, { column, beforeId, afterId })
28
+ if (task.column !== before.column) {
29
+ store.addTransition({
30
+ taskId: id, fromCol: before.column, toCol: task.column, source, detail,
31
+ })
32
+ }
33
+ const limit = wipLimitField(config, task.column)
34
+ const count = store.countInColumn(task.board, task.column)
35
+ return { task, overLimit: limit !== undefined && count > limit }
36
+ }
37
+
38
+ /** Create local board task (not linked to Gitea). */
39
+ export function createTask({ store, input }) {
40
+ const title = typeof input?.title === 'string' ? input.title.trim() : ''
41
+ if (title === '') return { error: 'title-required', status: 400 }
42
+ const board = input?.board ?? 'main'
43
+ // Validate column against board kind; simple boards do not have deploy/cleanup.
44
+ const allowed = columnsOf(store.boardKind(board))
45
+ const column = allowed.includes(input?.column) ? input.column : 'backlog'
46
+ const task = store.createTask({
47
+ board,
48
+ column,
49
+ title,
50
+ body: typeof input?.body === 'string' ? input.body : '',
51
+ labels: Array.isArray(input?.labels) ? input.labels.filter((l) => typeof l === 'string') : [],
52
+ })
53
+ return { task }
54
+ }
55
+
56
+ /** Update card fields. Column transitions are handled via applyMove. */
57
+ export function updateTask({ store, id, input }) {
58
+ const current = store.getTask(id)
59
+ if (current === undefined) return { error: 'task-not-found', status: 404 }
60
+ const patch = {}
61
+ if (typeof input?.title === 'string' && input.title.trim() !== '') patch.title = input.title.trim()
62
+ if (typeof input?.body === 'string') patch.body = input.body
63
+ if (Array.isArray(input?.labels)) patch.labels = input.labels.filter((l) => typeof l === 'string')
64
+ // Priority is local to board and not synced back to Gitea.
65
+ if (PRIORITIES.includes(input?.priority)) patch.priority = input.priority
66
+ // Deadline is local. 0 clears deadline; positive value sets it.
67
+ if (typeof input?.dueAt === 'number' && Number.isFinite(input.dueAt) && input.dueAt >= 0) {
68
+ patch.dueAt = Math.floor(input.dueAt)
69
+ }
70
+ if (typeof input?.cron === 'string') {
71
+ patch.cron = input.cron.trim()
72
+ patch.nextRunAt = patch.cron ? (computeNextRun(patch.cron) || 0) : 0
73
+ }
74
+ if (Array.isArray(input?.checklist)) patch.checklist = input.checklist
75
+ if (input?.report && typeof input.report === 'object') patch.report = input.report
76
+ if (Array.isArray(input?.comments)) patch.comments = input.comments
77
+ if (typeof input?.rejectReason === 'string') patch.rejectReason = input.rejectReason
78
+ if (typeof input?.progressDump === 'string') {
79
+ patch.progressDump = input.progressDump
80
+ }
81
+ if (typeof input?.permission === 'string') {
82
+ patch.permission = input.permission
83
+ if (input.permission !== current.permission) {
84
+ patch.permissionConfirmedAt = 0
85
+ patch.permissionConfirmedTier = ''
86
+ }
87
+ }
88
+
89
+ if (typeof input?.reasoningEffort === 'string') {
90
+ const val = input.reasoningEffort.trim().toLowerCase()
91
+ if (['', 'low', 'medium', 'high', 'default', 'none', 'minimal', 'thorough'].includes(val)) {
92
+ patch.reasoningEffort = val
93
+ }
94
+ }
95
+ if (typeof input?.forge === 'string') {
96
+ const val = input.forge.trim().toLowerCase()
97
+ if (val === 'github' || val === 'gitea') patch.forge = val
98
+ }
99
+ return { task: store.updateTask(id, patch) }
100
+ }
101
+
102
+ export function confirmPermission({ store, id, tier }) {
103
+ const task = store.getTask(id)
104
+ if (task === undefined) return { error: 'task-not-found', status: 404 }
105
+ const effectiveTier = typeof tier === 'string' && tier ? tier : (task.permission || 'default')
106
+ return { task: store.updateTask(id, { permissionConfirmedAt: Date.now(), permissionConfirmedTier: effectiveTier }) }
107
+ }
108
+
109
+ /** Known priority values. Empty string indicates unprioritized. */
110
+ const PRIORITIES = ['', 'high', 'medium', 'low']
111
+
112
+ /**
113
+ * Append note to task body.
114
+ * Handled on server to prevent overwriting edits made elsewhere.
115
+ */
116
+ export function appendNote({ store, id, input }) {
117
+ const task = store.getTask(id)
118
+ if (task === undefined) return { error: 'task-not-found', status: 404 }
119
+ const note = typeof input?.text === 'string' ? input.text.trim() : ''
120
+ if (note === '') return { error: 'note-required', status: 400 }
121
+ const body = typeof task.body === 'string' ? task.body : ''
122
+ return { task: store.updateTask(id, { body: body === '' ? note : `${body}\n\n${note}` }) }
123
+ }
124
+
125
+ /**
126
+ * Archive or unarchive card.
127
+ * Unarchiving restores card to its original column and position.
128
+ */
129
+ export function setArchived({ store, id, archived, now = Date.now() }) {
130
+ const task = store.getTask(id)
131
+ if (task === undefined) return { error: 'task-not-found', status: 404 }
132
+ return { task: store.setArchived(id, archived ? now : 0) }
133
+ }
134
+
135
+ /** Query archived tasks list. */
136
+ export function listArchive({ store }) {
137
+ return { tasks: store.listArchived() }
138
+ }
139
+
140
+ /**
141
+ * Assign task or clear assignment.
142
+ * Empty login unassigns the task.
143
+ *
144
+ * @returns {{task: object}|{error: string, status: number}}
145
+ */
146
+ export function assignTask({ store, id, login }) {
147
+ const task = store.getTask(id)
148
+ if (task === undefined) return { error: 'task-not-found', status: 404 }
149
+ const who = typeof login === 'string' ? login.trim() : ''
150
+ if (who === (task.assignee ?? '')) return { task }
151
+ store.updateTask(id, { assignee: who })
152
+ store.addTransition({
153
+ taskId: id,
154
+ fromCol: task.column,
155
+ toCol: task.column,
156
+ source: 'manual',
157
+ detail: who === '' ? 'unassigned' : `assigned: ${who}`,
158
+ })
159
+ return { task: store.getTask(id) }
160
+ }
161
+
162
+ export function deleteTask({ store, id }) {
163
+ if (store.getTask(id) === undefined) return { error: 'task-not-found', status: 404 }
164
+ store.deleteTask(id)
165
+ return { ok: true }
166
+ }
167
+
168
+ /**
169
+ * Parse task markdown body into structured block elements.
170
+ * Done on server so client bundle does not need markdown parser.
171
+ */
172
+ /**
173
+ * Board metrics: WIP distribution and stale tasks.
174
+ *
175
+ * @returns {{columns: object[], done: object, stale: object[], tasks: number}}
176
+ */
177
+
178
+ export function canRevive(task, live) {
179
+ if (task?.stalled) return true
180
+ const has = typeof task?.sessionId === 'string' && task.sessionId !== ''
181
+ return has && (live === undefined || live === null)
182
+ }
183
+
184
+ export function taskBody({ store, id }) {
185
+ const task = store.getTask(id)
186
+ if (task === undefined) return { error: 'task-not-found', status: 404 }
187
+ return { blocks: parseBody(task.body) }
188
+ }
189
+
190
+ export function taskLog({ store, id }) {
191
+ if (store.getTask(id) === undefined) return { error: 'task-not-found', status: 404 }
192
+ return { transitions: store.listTransitions(id) }
193
+ }
194
+
195
+ /** Find task associated with session; absence is normal response. */
196
+ export function taskBySession({ store, sessionId, agents }) {
197
+ const task = store.findTaskBySession(sessionId)
198
+ if (task === undefined) return { task: null }
199
+ let live
200
+ try { live = agents?.get?.(sessionId) } catch { live = undefined }
201
+ // Board kind returned with task so UI displays matching column choices.
202
+ return {
203
+ task,
204
+ columns: columnsOf(store.boardKind(task.board)),
205
+ canRevive: canRevive(task, live),
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Export full board snapshot for backup or migration.
211
+ */