@goodandready/dsh-kanban 0.2.35 → 0.2.37

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,270 @@
1
+ // Pull Request description and release notes generator (#379).
2
+ // Zero Cyrillic literals in lib/ per Issue #366.
3
+
4
+ import { inferScope } from './plan-generator.js'
5
+
6
+ /**
7
+ * Parse one-line git log output into structured commit records.
8
+ *
9
+ * @param {string} logText Raw git log output
10
+ * @returns {Array<{ sha: string, message: string }>}
11
+ */
12
+ export function parseGitLog(logText) {
13
+ if (typeof logText !== 'string' || !logText.trim()) return []
14
+ const lines = logText.trim().split('\n')
15
+ const commits = []
16
+
17
+ for (const line of lines) {
18
+ const trimmed = line.trim()
19
+ if (!trimmed) continue
20
+
21
+ // Tab-delimited (git log --pretty=format:%h%x09%s) or space-delimited
22
+ let sha = ''
23
+ let message = ''
24
+ if (trimmed.includes('\t')) {
25
+ const parts = trimmed.split('\t')
26
+ sha = parts[0].trim()
27
+ message = parts.slice(1).join('\t').trim()
28
+ } else {
29
+ const match = trimmed.match(/^([a-f0-9]{7,40})\s+(.+)$/i)
30
+ if (match) {
31
+ sha = match[1]
32
+ message = match[2].trim()
33
+ } else {
34
+ message = trimmed
35
+ }
36
+ }
37
+
38
+ if (sha || message) {
39
+ commits.push({ sha, message })
40
+ }
41
+ }
42
+
43
+ return commits
44
+ }
45
+
46
+ /**
47
+ * Parse git diff --stat output into structured file and line counts.
48
+ *
49
+ * @param {string} statText Raw git diff --stat output
50
+ * @returns {{ filesChanged: number, additions: number, deletions: number, files: Array<{ path: string, changes: string }> }}
51
+ */
52
+ export function parseDiffStat(statText) {
53
+ if (typeof statText !== 'string' || !statText.trim()) {
54
+ return { filesChanged: 0, additions: 0, deletions: 0, files: [] }
55
+ }
56
+
57
+ const lines = statText.trim().split('\n')
58
+ const files = []
59
+ let filesChanged = 0
60
+ let additions = 0
61
+ let deletions = 0
62
+
63
+ for (const line of lines) {
64
+ const trimmed = line.trim()
65
+ if (!trimmed) continue
66
+
67
+ // Summary line: "3 files changed, 45 insertions(+), 12 deletions(-)"
68
+ const summaryMatch = trimmed.match(/(\d+)\s+files?\s+changed(?:,\s+(\d+)\s+insertions?\(\+\))?(?:,\s+(\d+)\s+deletions?\(-\))?/)
69
+ if (summaryMatch) {
70
+ filesChanged = parseInt(summaryMatch[1], 10) || 0
71
+ additions = parseInt(summaryMatch[2], 10) || 0
72
+ deletions = parseInt(summaryMatch[3], 10) || 0
73
+ continue
74
+ }
75
+
76
+ // File line: "lib/client.js | 14 +++++++-------"
77
+ const fileMatch = trimmed.match(/^(.+?)\s+\|\s+(\d+)\s*(.*)$/)
78
+ if (fileMatch) {
79
+ files.push({
80
+ path: fileMatch[1].trim(),
81
+ changes: fileMatch[2].trim(),
82
+ })
83
+ }
84
+ }
85
+
86
+ if (filesChanged === 0 && files.length > 0) {
87
+ filesChanged = files.length
88
+ }
89
+
90
+ return { filesChanged, additions, deletions, files }
91
+ }
92
+
93
+ /**
94
+ * Infer conventional commit / PR type (feat, fix, refactor, perf, docs, chore).
95
+ *
96
+ * @param {object} task Task object
97
+ * @param {Array<{ sha: string, message: string }>} [commits]
98
+ * @returns {string} PR type
99
+ */
100
+ export function inferPrType(task, commits = []) {
101
+ const title = String(task?.title ?? '').toLowerCase()
102
+ const labels = Array.isArray(task?.labels) ? task.labels.map((l) => String(l).toLowerCase()) : []
103
+
104
+ for (const label of labels) {
105
+ if (label.startsWith('type/')) {
106
+ const t = label.slice(5)
107
+ if (t === 'bug') return 'fix'
108
+ if (t === 'feature') return 'feat'
109
+ return t
110
+ }
111
+ }
112
+
113
+ if (title.startsWith('fix:') || title.includes('fix') || title.includes('bug')) return 'fix'
114
+ if (title.startsWith('refactor:') || title.includes('refactor')) return 'refactor'
115
+ if (title.startsWith('perf:') || title.includes('performance')) return 'perf'
116
+ if (title.startsWith('docs:') || title.includes('document')) return 'docs'
117
+ if (title.startsWith('chore:') || title.includes('cleanup')) return 'chore'
118
+
119
+ for (const c of commits) {
120
+ const msg = String(c?.message ?? '').toLowerCase()
121
+ if (msg.startsWith('fix')) return 'fix'
122
+ if (msg.startsWith('refactor')) return 'refactor'
123
+ if (msg.startsWith('perf')) return 'perf'
124
+ }
125
+
126
+ return 'feat'
127
+ }
128
+
129
+ /**
130
+ * Generate canonical PR title according to conventional commits format.
131
+ *
132
+ * @param {object} task Task object
133
+ * @param {Array<{ sha: string, message: string }>} [commits]
134
+ * @returns {string} PR title
135
+ */
136
+ export function generatePrTitle(task, commits = []) {
137
+ const type = inferPrType(task, commits)
138
+ const scope = inferScope(task)
139
+ let cleanTitle = String(task?.title ?? '').trim()
140
+
141
+ // Strip leading prefixes like "L: Feature:", "M: Fix:", "feat:", etc.
142
+ cleanTitle = cleanTitle.replace(/^[A-Z]:\s*(Feature|Bug|Fix|Refactor|Task):\s*/i, '')
143
+ cleanTitle = cleanTitle.replace(/^(feat|fix|refactor|perf|docs|chore)(\([^)]+\))?:\s*/i, '')
144
+
145
+ const issueNum = typeof task?.issueNumber === 'number' ? task.issueNumber : null
146
+ const issueRef = issueNum ? ` (#${issueNum})` : ''
147
+
148
+ return `${type}(${scope}): ${cleanTitle}${issueRef}`
149
+ }
150
+
151
+ /**
152
+ * Generate canonical, structured Pull Request description markdown (#379).
153
+ *
154
+ * @param {object} params
155
+ * @param {object} params.task Task record
156
+ * @param {Array<{ sha: string, message: string }>} [params.commits]
157
+ * @param {object} [params.diffStats]
158
+ * @param {object} [params.verification]
159
+ * @returns {string} Complete Markdown description for PR
160
+ */
161
+ export function generatePrDescription({ task, commits = [], diffStats = null, verification = null }) {
162
+ const title = String(task?.title ?? '').trim()
163
+ const body = String(task?.body ?? '').trim()
164
+ const issueNum = typeof task?.issueNumber === 'number' ? task.issueNumber : null
165
+ const issueRef = issueNum ? `Closes #${issueNum}` : ''
166
+
167
+ const lines = []
168
+
169
+ // 1. Problem statement
170
+ lines.push('### Problem')
171
+ if (body) {
172
+ lines.push(body)
173
+ } else {
174
+ lines.push(`Implementation required for: ${title}`)
175
+ }
176
+ lines.push('')
177
+
178
+ // 2. Solution description
179
+ lines.push('### Solution')
180
+ if (task?.spec) {
181
+ // Extract key sections or summary from spec if present
182
+ const specLines = task.spec.split('\n')
183
+ const solutionLines = specLines.filter((l) => !l.startsWith('# Technical Specification') && !l.startsWith('## 1.'))
184
+ lines.push(solutionLines.slice(0, 15).join('\n').trim())
185
+ } else if (Array.isArray(task?.plan?.items) && task.plan.items.length > 0) {
186
+ lines.push('Implemented following plan:')
187
+ for (const item of task.plan.items) {
188
+ lines.push(`- ${item}`)
189
+ }
190
+ } else {
191
+ lines.push(`- Resolved requirements specified in ${title}`)
192
+ }
193
+ lines.push('')
194
+
195
+ // 3. Changed components & diff statistics
196
+ if (diffStats && (diffStats.files?.length > 0 || diffStats.filesChanged > 0)) {
197
+ lines.push('### Changed Components & Files')
198
+ lines.push(`- Total files changed: ${diffStats.filesChanged || diffStats.files.length}`)
199
+ if (diffStats.additions || diffStats.deletions) {
200
+ lines.push(`- Changes: +${diffStats.additions} / -${diffStats.deletions}`)
201
+ }
202
+ if (Array.isArray(diffStats.files) && diffStats.files.length > 0) {
203
+ for (const f of diffStats.files.slice(0, 12)) {
204
+ lines.push(` - \`${f.path}\``)
205
+ }
206
+ if (diffStats.files.length > 12) {
207
+ lines.push(` - ... and ${diffStats.files.length - 12} more files`)
208
+ }
209
+ }
210
+ lines.push('')
211
+ }
212
+
213
+ // 4. Commits list
214
+ if (Array.isArray(commits) && commits.length > 0) {
215
+ lines.push('### Commits')
216
+ for (const c of commits) {
217
+ const shaShort = c.sha ? `\`${c.sha.slice(0, 7)}\` ` : ''
218
+ lines.push(`- ${shaShort}${c.message}`)
219
+ }
220
+ lines.push('')
221
+ }
222
+
223
+ // 5. Verification status
224
+ const ver = verification || task?.report?.verification
225
+ if (ver) {
226
+ lines.push('### Verification Results')
227
+ const statusText = ver.status === 'passed' ? '✓ Tests Passed' : (ver.status === 'failed' ? '✗ Tests Failed' : 'Pending')
228
+ lines.push(`- Status: **${statusText}**`)
229
+ if (ver.command) {
230
+ lines.push(`- Command: \`${ver.command}\``)
231
+ }
232
+ if (ver.verifiedAt) {
233
+ lines.push(`- Timestamp: ${new Date(ver.verifiedAt).toISOString()}`)
234
+ }
235
+ lines.push('')
236
+ }
237
+
238
+ // 6. Issue reference closure
239
+ if (issueRef) {
240
+ lines.push(issueRef)
241
+ }
242
+
243
+ return lines.join('\n').trim()
244
+ }
245
+
246
+ /**
247
+ * Generate release notes entry for CHANGELOG.md.
248
+ *
249
+ * @param {object} params
250
+ * @param {object} params.task
251
+ * @param {Array<{ sha: string, message: string }>} [params.commits]
252
+ * @returns {string} Formatted release notes bullet
253
+ */
254
+ export function generateReleaseNotes({ task, commits = [] }) {
255
+ const title = String(task?.title ?? '').trim()
256
+ const issueNum = typeof task?.issueNumber === 'number' ? ` (#${task.issueNumber})` : ''
257
+ const cleanTitle = title.replace(/^[A-Z]:\s*(Feature|Bug|Fix|Refactor|Task):\s*/i, '')
258
+
259
+ const lines = [`- **${cleanTitle}${issueNum}**:`]
260
+
261
+ if (Array.isArray(commits) && commits.length > 0) {
262
+ for (const c of commits.slice(0, 5)) {
263
+ lines.push(` - ${c.message}`)
264
+ }
265
+ } else {
266
+ lines.push(` - Implementation and automated verification complete.`)
267
+ }
268
+
269
+ return lines.join('\n')
270
+ }
@@ -4,6 +4,7 @@ import { columnStats, doneWithin, stale } from '../metrics.js'
4
4
  import { isStale } from '../time.js'
5
5
  import { facetsOf, facetsOfTask, groupByRepo, sortByLife } from '../filters.js'
6
6
  import { computeNextRun } from '../cron.js'
7
+ import { computeBlocksMap, checkTaskBlocked } from '../dependencies.js'
7
8
 
8
9
  export function withPlan(task, liveOf, config, now, sameSession) {
9
10
  const plan = parsePlan(task.plan)
@@ -74,12 +75,15 @@ export function buildBoard({ store, config, board = 'main', repo, liveOf, sync,
74
75
  if (!bySession.has(id)) bySession.set(id, [])
75
76
  bySession.get(id).push(task)
76
77
  }
78
+ const blocksMap = computeBlocksMap(raw)
77
79
  const tasks = orderForBoard(
78
80
  raw.map((task) => {
79
81
  const enriched = withPlan(task, liveOf, config, now, bySession.get(task.sessionId) ?? [])
80
82
  if (task.isEpic) {
81
83
  enriched.subtasksCount = store.countSubtasks(task.id)
82
84
  }
85
+ enriched.blocks = blocksMap[task.id] || []
86
+ enriched.isBlocked = checkTaskBlocked(task, store).isBlocked
83
87
  return enriched
84
88
  }),
85
89
  columnsOf(store.boardKind(board)),
@@ -1,3 +1,8 @@
1
+ import { isTimeMachineAvailable, listTaskSnapshots, createCheckpointSnapshot, rollbackToSnapshot } from '../time-machine.js'
2
+ import { generateTechnicalSpec, parseSpecMarkdown } from '../plan-generator.js'
3
+ import { generatePrTitle, generatePrDescription, generateReleaseNotes } from '../pr-generator.js'
4
+ import { getTaskCommits, getTaskDiffInspector } from '../worktree.js'
5
+
1
6
  /**
2
7
  * Extended actions for task route handlers (comments, reports, checklist, triage, subtasks, PR, nudge).
3
8
  * Zero Cyrillic in lib/ according to Issue #366.
@@ -249,7 +254,7 @@ export async function handleCreateTaskPr({ store, gitea, task, input }) {
249
254
  if (!head) return { status: 400, body: { error: 'branch-required' } }
250
255
  const base = String(input?.base || 'main').trim()
251
256
  const title = String(input?.title || task.title || `PR: ${task.title}`).trim()
252
- const body = String(input?.body || (task.issueNumber ? `Closes #${task.issueNumber}` : '')).trim()
257
+ const body = String(input?.body || task.prDescription || (task.issueNumber ? `Closes #${task.issueNumber}` : '')).trim()
253
258
 
254
259
  try {
255
260
  const pr = await gitea.createPullRequest({
@@ -343,3 +348,157 @@ export async function handleTaskNudge({ store, id, prompt, agents, createMessage
343
348
  },
344
349
  }
345
350
  }
351
+
352
+ /**
353
+ * Handle listing or creating snapshots via dsh-time-machine (#377).
354
+ */
355
+ export async function handleTaskSnapshot({ ctx, store, id, method, body, config, env, cwdOf, logger }) {
356
+ const task = store.getTask(id)
357
+ if (!task) return { status: 404, body: { error: 'task-not-found' } }
358
+
359
+ if (method === 'GET') {
360
+ const available = isTimeMachineAvailable(ctx)
361
+ const snapshots = available ? await listTaskSnapshots({ ctx, task }) : []
362
+ return {
363
+ status: 200,
364
+ body: {
365
+ available,
366
+ inceptionSnapshotId: task.inceptionSnapshotId || '',
367
+ snapshots,
368
+ },
369
+ }
370
+ }
371
+
372
+ if (method === 'POST') {
373
+ const label = typeof body?.label === 'string' ? body.label.trim() : ''
374
+ const res = await createCheckpointSnapshot({ ctx, task, label, config, env, cwdOf, logger })
375
+ if (!res.available) {
376
+ return {
377
+ status: 503,
378
+ body: {
379
+ error: 'time-machine-unavailable',
380
+ message: 'Plugin @goodandready/dsh-time-machine is not installed or active in this profile.',
381
+ },
382
+ }
383
+ }
384
+ if (!res.created) {
385
+ return { status: 500, body: { error: 'snapshot-failed', message: res.error } }
386
+ }
387
+ return { status: 201, body: { success: true, snapshot: res.snapshot } }
388
+ }
389
+
390
+ return { status: 405, body: { error: 'method-not-allowed' } }
391
+ }
392
+
393
+ /**
394
+ * Handle rolling back task worktree to inception or specified snapshot (#377).
395
+ */
396
+ export async function handleTaskRollbackSnapshot({ ctx, store, id, body, config, env, cwdOf, logger, eventHub }) {
397
+ const task = store.getTask(id)
398
+ if (!task) return { status: 404, body: { error: 'task-not-found' } }
399
+
400
+ const snapshotId = typeof body?.snapshotId === 'string' ? body.snapshotId.trim() : task.inceptionSnapshotId
401
+ const res = await rollbackToSnapshot({ ctx, task, snapshotId, config, env, cwdOf, logger })
402
+ if (!res.available) {
403
+ return { status: 503, body: { error: 'time-machine-unavailable', message: res.message } }
404
+ }
405
+ if (!res.rolledBack) {
406
+ return { status: 400, body: { error: res.error, message: res.message } }
407
+ }
408
+
409
+ if (eventHub && typeof eventHub.broadcast === 'function') {
410
+ eventHub.broadcast('task:change', { taskId: id, action: 'rollback', snapshotId: res.snapshotId })
411
+ }
412
+
413
+ return {
414
+ status: 200,
415
+ body: {
416
+ success: true,
417
+ snapshotId: res.snapshotId,
418
+ result: res.result,
419
+ },
420
+ }
421
+ }
422
+
423
+
424
+ /**
425
+ * Generate technical specification & plan for task (#376).
426
+ */
427
+ export async function handleTaskGeneratePlan({ store, id, body, eventHub }) {
428
+ const task = store.getTask(id)
429
+ if (!task) return { status: 404, body: { error: 'task-not-found' } }
430
+
431
+ const result = generateTechnicalSpec(task, body?.options || {})
432
+ const updated = store.updateTask(id, {
433
+ spec: result.spec,
434
+ plan: { items: result.items, current: 0 },
435
+ })
436
+
437
+ if (eventHub && typeof eventHub.broadcast === 'function') {
438
+ eventHub.broadcast('task:change', { taskId: id, action: 'plan-generated', spec: result.spec })
439
+ }
440
+
441
+ return {
442
+ status: 200,
443
+ body: {
444
+ success: true,
445
+ spec: result.spec,
446
+ items: result.items,
447
+ summary: result.summary,
448
+ task: updated,
449
+ },
450
+ }
451
+ }
452
+
453
+ /**
454
+ * Handle PR description generation and saving (#379).
455
+ */
456
+ export async function handleTaskPrDescription({ store, id, method, body, gitRunner }) {
457
+ const task = store.getTask(id)
458
+ if (!task) return { status: 404, body: { error: 'task-not-found' } }
459
+
460
+ if (method === 'POST' && typeof body?.prDescription === 'string') {
461
+ const updated = store.updateTask(id, { prDescription: body.prDescription })
462
+ return { status: 200, body: { success: true, prDescription: body.prDescription, task: updated } }
463
+ }
464
+
465
+ let commits = []
466
+ let diffStats = null
467
+
468
+ if (task.worktree) {
469
+ try {
470
+ commits = await getTaskCommits({ worktreePath: task.worktree, gitRunner })
471
+ } catch {
472
+ commits = []
473
+ }
474
+ try {
475
+ const inspector = await getTaskDiffInspector({ worktreePath: task.worktree, gitRunner })
476
+ if (inspector) {
477
+ diffStats = {
478
+ filesChanged: inspector.filesChanged || (inspector.files || []).length,
479
+ files: (inspector.files || []).map((f) => ({ path: f.file || f.path || f, changes: '' })),
480
+ }
481
+ }
482
+ } catch {
483
+ diffStats = null
484
+ }
485
+ }
486
+
487
+ const generatedTitle = generatePrTitle(task, commits)
488
+ const generatedDescription = generatePrDescription({ task, commits, diffStats })
489
+ const releaseNotes = generateReleaseNotes({ task, commits })
490
+
491
+ return {
492
+ status: 200,
493
+ body: {
494
+ success: true,
495
+ title: generatedTitle,
496
+ prDescription: task.prDescription || generatedDescription,
497
+ generatedDescription,
498
+ releaseNotes,
499
+ commits,
500
+ diffStats,
501
+ },
502
+ }
503
+ }
504
+
@@ -1,6 +1,7 @@
1
1
  import { parseBody } from '../markdown.js'
2
2
  import { columnsOf, wipLimitField } from '../config.js'
3
3
  import { withPlan } from './board.js'
4
+ import { checkMoveAllowed, validateDependencies, findUnblockedDependents } from '../dependencies.js'
4
5
 
5
6
  export function parseTaskPath(pathname) {
6
7
  const m = /^\/dsh-kanban\/task\/([^/]+)(?:\/([a-z-]+))?\/?$/.exec(pathname ?? '')
@@ -23,6 +24,17 @@ export function applyMove({ store, config, id, column, beforeId, afterId, source
23
24
  if (!allowed.includes(column)) {
24
25
  return { error: 'invalid-column', status: 400 }
25
26
  }
27
+ if (column === 'in-progress' && before.column !== 'in-progress') {
28
+ const moveCheck = checkMoveAllowed(before, column, store)
29
+ if (!moveCheck.allowed) {
30
+ return {
31
+ error: 'task-blocked',
32
+ status: 400,
33
+ message: moveCheck.reason,
34
+ pendingBlockers: moveCheck.pendingBlockers,
35
+ }
36
+ }
37
+ }
26
38
  }
27
39
  const task = store.moveTask(id, { column, beforeId, afterId })
28
40
  if (task.column !== before.column) {
@@ -32,7 +44,10 @@ export function applyMove({ store, config, id, column, beforeId, afterId, source
32
44
  }
33
45
  const limit = wipLimitField(config, task.column)
34
46
  const count = store.countInColumn(task.board, task.column)
35
- return { task, overLimit: limit !== undefined && count > limit }
47
+ const unblocked = (task.column === 'done' && before.column !== 'done')
48
+ ? findUnblockedDependents(id, store, () => store.listTasks({ board: task.board }))
49
+ : []
50
+ return { task, overLimit: limit !== undefined && count > limit, unblocked }
36
51
  }
37
52
 
38
53
  /** Create local board task (not linked to Gitea). */
@@ -96,6 +111,17 @@ export function updateTask({ store, id, input }) {
96
111
  const val = input.forge.trim().toLowerCase()
97
112
  if (val === 'github' || val === 'gitea') patch.forge = val
98
113
  }
114
+ if (Array.isArray(input?.blockedBy)) {
115
+ const newBlockedBy = input.blockedBy.filter((b) => typeof b === 'string' && b.trim() !== '')
116
+ const check = validateDependencies(id, newBlockedBy, store)
117
+ if (!check.valid) {
118
+ return { error: 'circular-dependency', status: 400, message: check.error }
119
+ }
120
+ patch.blockedBy = newBlockedBy
121
+ }
122
+ if (typeof input?.inceptionSnapshotId === 'string') {
123
+ patch.inceptionSnapshotId = input.inceptionSnapshotId.trim()
124
+ }
99
125
  return { task: store.updateTask(id, patch) }
100
126
  }
101
127
 
package/lib/routes.js CHANGED
@@ -69,4 +69,8 @@ export {
69
69
  handleToggleChecklistItem,
70
70
  handleCreateTaskPr,
71
71
  handleTaskNudge,
72
+ handleTaskSnapshot,
73
+ handleTaskRollbackSnapshot,
74
+ handleTaskGeneratePlan,
75
+ handleTaskPrDescription,
72
76
  } from './routes/task-actions.js'
@@ -30,6 +30,8 @@ import {
30
30
  handleToggleChecklistItem,
31
31
  } from '../routes.js'
32
32
  import { verifyAndRecordGate } from '../verification.js'
33
+ import { handleTaskSnapshot, handleTaskRollbackSnapshot, handleTaskGeneratePlan, handleTaskPrDescription } from '../routes.js'
34
+ import { createInceptionSnapshot } from '../time-machine.js'
33
35
  import { runTask, queueTask, unqueueTask, resolveModel, buildStartMessage, PERMISSION_RANKS } from '../launcher.js'
34
36
  import { dispatchMove, stopWork, MOVE_DETAIL, STOP_DETAIL } from '../commands.js'
35
37
  import { checkWorktreeDirty } from '../worktree.js'
@@ -257,6 +259,25 @@ export function createTaskRouteHandler(deps) {
257
259
  out.dirtyFiles = dirty.files
258
260
  }
259
261
  }
262
+ if (out.task.column === 'in-progress' && was?.column !== 'in-progress') {
263
+ const cfg = getConfig()
264
+ if (cfg?.timeMachineEnabled !== false && cfg?.timeMachineAutoSnapshot !== false) {
265
+ createInceptionSnapshot({
266
+ ctx,
267
+ task: out.task,
268
+ config: cfg,
269
+ env: process.env,
270
+ cwdOf: () => rootOf(cfg, () => process.cwd()).path,
271
+ store,
272
+ logger: ctx.logger,
273
+ }).catch(() => {})
274
+ }
275
+ }
276
+ if (Array.isArray(out.unblocked) && out.unblocked.length > 0) {
277
+ for (const dep of out.unblocked) {
278
+ eventHub.broadcast('task:change', { taskId: dep.id, action: 'unblocked', task: dep })
279
+ }
280
+ }
260
281
  }
261
282
  reply(res, out)
262
283
  return
@@ -400,6 +421,36 @@ export function createTaskRouteHandler(deps) {
400
421
  writeJson(res, resObj.status, resObj.body)
401
422
  return
402
423
  }
424
+ if ((req.method === 'GET' || req.method === 'POST') && action === 'snapshot') {
425
+ const cfg = getConfig()
426
+ const resObj = await handleTaskSnapshot({
427
+ ctx, store, id, method: req.method, body: input, config: cfg,
428
+ env: process.env, cwdOf: () => rootOf(cfg, () => process.cwd()).path, logger: ctx.logger,
429
+ })
430
+ writeJson(res, resObj.status, resObj.body)
431
+ return
432
+ }
433
+ if (req.method === 'POST' && action === 'rollback-snapshot') {
434
+ const cfg = getConfig()
435
+ const resObj = await handleTaskRollbackSnapshot({
436
+ ctx, store, id, body: input, config: cfg,
437
+ env: process.env, cwdOf: () => rootOf(cfg, () => process.cwd()).path, logger: ctx.logger, eventHub,
438
+ })
439
+ writeJson(res, resObj.status, resObj.body)
440
+ return
441
+ }
442
+ if (req.method === 'POST' && action === 'generate-plan') {
443
+ const resObj = await handleTaskGeneratePlan({ store, id, body: input, eventHub })
444
+ writeJson(res, resObj.status, resObj.body)
445
+ return
446
+ }
447
+ if ((req.method === 'GET' || req.method === 'POST') && action === 'pr-description') {
448
+ const cfg = getConfig()
449
+ const gitRunner = cfg.remoteMode ? createRemoteGitRunner(cfg) : undefined
450
+ const resObj = await handleTaskPrDescription({ store, id, method: req.method, body: input, gitRunner })
451
+ writeJson(res, resObj.status, resObj.body)
452
+ return
453
+ }
403
454
  if (req.method === 'POST' && action === 'resume') {
404
455
  watchdog?.resumeTask?.({ store, taskId: id, eventHub })
405
456
  const task = store.getTask(id)
package/lib/store.js CHANGED
@@ -79,6 +79,10 @@ CREATE TABLE IF NOT EXISTS tasks (
79
79
  stalled INTEGER NOT NULL DEFAULT 0,
80
80
  stalledReason TEXT NOT NULL DEFAULT '',
81
81
  stalledAt INTEGER NOT NULL DEFAULT 0,
82
+ blockedBy TEXT NOT NULL DEFAULT '[]',
83
+ inceptionSnapshotId TEXT NOT NULL DEFAULT '',
84
+ spec TEXT NOT NULL DEFAULT '',
85
+ prDescription TEXT NOT NULL DEFAULT '',
82
86
  updatedAt INTEGER NOT NULL
83
87
  );
84
88
  CREATE INDEX IF NOT EXISTS tasks_board_col ON tasks(board, col, position);
@@ -114,6 +118,7 @@ const WRITABLE = [
114
118
  'verificationCommand', 'verificationStatus',
115
119
  'costMetrics', 'costLimitExceeded',
116
120
  'stalled', 'stalledReason', 'stalledAt',
121
+ 'blockedBy', 'inceptionSnapshotId', 'spec', 'prDescription',
117
122
  ]
118
123
 
119
124
  /** Valid transition sources; unexpected values are rejected to preserve log integrity. */
@@ -121,7 +126,7 @@ const SOURCES = new Set(['manual', 'session', 'gitea', 'github', 'tool', 'workfl
121
126
 
122
127
  function rowToTask(row) {
123
128
  if (row === undefined) return undefined
124
- const { col, labels, waiting, labelColors, runs, checklist, report, comments, rejectReason, rejectedAt, isEpic, parentId, stalled, ...rest } = row
129
+ const { col, labels, waiting, labelColors, runs, checklist, report, comments, rejectReason, rejectedAt, isEpic, parentId, stalled, blockedBy, inceptionSnapshotId, spec, prDescription, ...rest } = row
125
130
  let parsedChecklist = []
126
131
  try {
127
132
  const value = JSON.parse(checklist ?? '[]')
@@ -177,6 +182,15 @@ function rowToTask(row) {
177
182
  return rest.costMetrics && typeof rest.costMetrics === 'object' ? rest.costMetrics : null;
178
183
  })(),
179
184
  costLimitExceeded: rest.costLimitExceeded === 1 || rest.costLimitExceeded === true,
185
+ blockedBy: (() => {
186
+ try {
187
+ const val = JSON.parse(blockedBy ?? '[]')
188
+ return Array.isArray(val) ? val.filter((x) => typeof x === 'string') : []
189
+ } catch { return [] }
190
+ })(),
191
+ inceptionSnapshotId: typeof inceptionSnapshotId === 'string' ? inceptionSnapshotId : (typeof rest.inceptionSnapshotId === 'string' ? rest.inceptionSnapshotId : ''),
192
+ spec: typeof spec === 'string' ? spec : (typeof rest.spec === 'string' ? rest.spec : ''),
193
+ prDescription: typeof prDescription === 'string' ? prDescription : (typeof rest.prDescription === 'string' ? rest.prDescription : ''),
180
194
  }
181
195
  }
182
196
 
@@ -272,6 +286,10 @@ export function openStore({ dir }) {
272
286
  ['stalled', 'INTEGER NOT NULL DEFAULT 0'],
273
287
  ['stalledReason', "TEXT NOT NULL DEFAULT ''"],
274
288
  ['stalledAt', 'INTEGER NOT NULL DEFAULT 0'],
289
+ ['blockedBy', "TEXT NOT NULL DEFAULT '[]'"],
290
+ ['inceptionSnapshotId', "TEXT NOT NULL DEFAULT ''"],
291
+ ['spec', "TEXT NOT NULL DEFAULT ''"],
292
+ ['prDescription', "TEXT NOT NULL DEFAULT ''"],
275
293
  ]) {
276
294
  try { db.exec(`ALTER TABLE tasks ADD COLUMN ${column} ${definition}`) } catch { /* column already exists */ }
277
295
  }
@@ -602,7 +620,8 @@ export function openStore({ dir }) {
602
620
  for (const key of WRITABLE) {
603
621
  if (!Object.hasOwn(patch, key)) continue
604
622
  fields.push(`${key} = ?`)
605
- if (key === 'labels') values.push(JSON.stringify(patch[key] ?? []))
623
+ if (key === 'plan') values.push(typeof patch[key] === 'string' ? patch[key] : JSON.stringify(patch[key] ?? { items: [], current: 0 }))
624
+ else if (key === 'labels') values.push(JSON.stringify(patch[key] ?? []))
606
625
  else if (key === 'labelColors') values.push(JSON.stringify(patch[key] ?? {}))
607
626
  else if (key === 'runs') values.push(typeof patch[key] === 'string' ? patch[key] : JSON.stringify(patch[key] ?? []))
608
627
  else if (key === 'checklist') values.push(typeof patch[key] === 'string' ? patch[key] : JSON.stringify(patch[key] ?? []))
@@ -614,6 +633,10 @@ export function openStore({ dir }) {
614
633
  else if (key === 'syncedAt') values.push(Number(patch[key]) || 0)
615
634
  else if (key === 'isEpic') values.push(patch[key] ? 1 : 0)
616
635
  else if (key === 'parentId') values.push(String(patch[key] ?? ''))
636
+ else if (key === 'blockedBy') values.push(Array.isArray(patch[key]) ? JSON.stringify(patch[key]) : (typeof patch[key] === 'string' ? patch[key] : '[]'))
637
+ else if (key === 'inceptionSnapshotId') values.push(String(patch[key] ?? ''))
638
+ else if (key === 'spec') values.push(String(patch[key] ?? ''))
639
+ else if (key === 'prDescription') values.push(String(patch[key] ?? ''))
617
640
  else values.push(patch[key] ?? null)
618
641
  }
619
642
  if (fields.length > 0) {