@goodandready/dsh-kanban 0.2.36 → 0.2.38

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/lib/config.js CHANGED
@@ -96,6 +96,8 @@ export const CONFIG_DEFAULTS = {
96
96
  watchdogMaxConsecutiveErrors: 5,
97
97
  timeMachineEnabled: true,
98
98
  timeMachineAutoSnapshot: true,
99
+ notificationsEnabled: false,
100
+ audioAlertsEnabled: false,
99
101
  }
100
102
 
101
103
  /** Field descriptions reused in schema and settings card. */
@@ -151,6 +153,8 @@ export const CONFIG_HINTS = {
151
153
  watchdogMaxConsecutiveErrors: 'Number of consecutive execution errors before tripping circuit breaker (default: 5).',
152
154
  timeMachineEnabled: 'Enable integration with @goodandready/dsh-time-machine for task checkpoints and rollback.',
153
155
  timeMachineAutoSnapshot: 'Automatically create inception snapshot when task transitions to in-progress.',
156
+ notificationsEnabled: 'Enable desktop push notifications for key task events.',
157
+ audioAlertsEnabled: 'Enable Web Audio synthesized audio alerts on task transitions.',
154
158
  }
155
159
 
156
160
  /** Setting key for column WIP limit; not all columns have limits. */
package/lib/launcher.js CHANGED
@@ -11,6 +11,7 @@
11
11
  // are passed as parameters to support unit testing without harness runtime.
12
12
 
13
13
  import { isAbsolute, join, resolve, sep } from 'node:path'
14
+ import { formatPlanPrompt } from './plan-generator.js'
14
15
  import { randomUUID } from 'node:crypto'
15
16
  import { createTaskWorktree, registerDshWorkspace, isGitRepo } from './worktree.js'
16
17
  import { formatHandoverPreamble, parseProgressDump } from './progress-dump.js'
@@ -90,10 +91,13 @@ export function buildStartMessage(task, config) {
90
91
  ? `Task ${vars.repo}#${vars.issueNumber}: ${vars.title}`
91
92
  : `Task: ${vars.title}`
92
93
 
94
+ const planBlock = formatPlanPrompt(task)
95
+
93
96
  return joinBlocks([
94
97
  head,
95
98
  vars.body,
96
99
  fromIssue && vars.issueUrl !== '' ? `Link: ${vars.issueUrl}` : '',
100
+ planBlock,
97
101
  fromIssue ? WORKFLOW_TAIL : OWN_TAIL,
98
102
  note,
99
103
  ])
@@ -0,0 +1,226 @@
1
+ // Technical specification and execution plan generator for backlog/todo tasks (#376).
2
+ // Zero Cyrillic literals in lib/ per Issue #366.
3
+
4
+ import { MAX_ITEMS } from './plan.js'
5
+
6
+ /**
7
+ * Truncate clean item text to reasonable single-line display length.
8
+ *
9
+ * @param {string} text Raw item text
10
+ * @returns {string} Truncated clean item
11
+ */
12
+ function cleanItemText(text) {
13
+ const cleaned = String(text ?? '').replace(/\s+/g, ' ').trim()
14
+ return cleaned.length > 120 ? cleaned.slice(0, 119) + '…' : cleaned
15
+ }
16
+
17
+ /**
18
+ * Infer primary scope category from title, labels or body.
19
+ *
20
+ * @param {object} task Task record
21
+ * @returns {string} Scope name
22
+ */
23
+ export function inferScope(task) {
24
+ const title = String(task?.title ?? '').toLowerCase()
25
+ const labels = Array.isArray(task?.labels) ? task.labels.map((l) => String(l).toLowerCase()) : []
26
+ for (const label of labels) {
27
+ if (label.startsWith('scope/')) return label.slice(6)
28
+ }
29
+ if (title.includes('ui') || title.includes('view') || title.includes('board') || title.includes('card')) return 'webui'
30
+ if (title.includes('tool') || title.includes('agent')) return 'agent-tools'
31
+ if (title.includes('setting') || title.includes('config')) return 'settings'
32
+ if (title.includes('worktree') || title.includes('branch') || title.includes('git')) return 'worktree'
33
+ if (title.includes('security') || title.includes('auth')) return 'security'
34
+ if (title.includes('route') || title.includes('api') || title.includes('server')) return 'routing'
35
+ return 'core'
36
+ }
37
+
38
+ /**
39
+ * Infer candidate files touched from task description and scope.
40
+ *
41
+ * @param {object} task Task record
42
+ * @param {string} scope Inferred or explicit scope
43
+ * @returns {string[]} List of candidate file paths
44
+ */
45
+ export function inferCandidateFiles(task, scope) {
46
+ const body = String(task?.body ?? '')
47
+ const title = String(task?.title ?? '')
48
+ const combined = `${title}\n${body}`
49
+
50
+ const found = new Set()
51
+ // Match explicit file paths mentioned in markdown
52
+ const pathMatches = combined.match(/[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_./-]+\.(js|mjs|json|md|yml|yaml|css|html)/g)
53
+ if (pathMatches) {
54
+ for (const p of pathMatches) {
55
+ if (!p.startsWith('node_modules/') && !p.startsWith('.git/')) {
56
+ found.add(p)
57
+ }
58
+ }
59
+ }
60
+
61
+ // Add scope-based canonical files if set is small
62
+ if (found.size === 0) {
63
+ if (scope === 'webui') {
64
+ found.add('lib/client.js')
65
+ found.add('test/board-ui.test.mjs')
66
+ } else if (scope === 'settings') {
67
+ found.add('lib/config.js')
68
+ found.add('lib/client.js')
69
+ found.add('test/config.test.mjs')
70
+ } else if (scope === 'agent-tools') {
71
+ found.add('lib/board-tool.js')
72
+ found.add('test/board-tool.test.mjs')
73
+ } else if (scope === 'worktree') {
74
+ found.add('lib/worktree.js')
75
+ found.add('test/worktree.test.mjs')
76
+ } else if (scope === 'routing') {
77
+ found.add('lib/routes.js')
78
+ found.add('lib/server/route-handlers.js')
79
+ found.add('test/routes.test.mjs')
80
+ } else {
81
+ found.add('lib/store.js')
82
+ found.add('test/store.test.mjs')
83
+ }
84
+ found.add('CHANGELOG.md')
85
+ found.add('docs/design/DESIGN.md')
86
+ }
87
+
88
+ return Array.from(found).slice(0, 10)
89
+ }
90
+
91
+ /**
92
+ * Generate technical specification and structured plan for a backlog task (#376).
93
+ *
94
+ * @param {object} task Task object
95
+ * @param {object} [options]
96
+ * @param {string} [options.customInstructions]
97
+ * @param {string[]} [options.additionalFiles]
98
+ * @returns {{ spec: string, items: string[], summary: string, scope: string }}
99
+ */
100
+ export function generateTechnicalSpec(task, options = {}) {
101
+ const title = String(task?.title ?? '').trim()
102
+ const body = String(task?.body ?? '').trim()
103
+ const issueNum = typeof task?.issueNumber === 'number' ? task.issueNumber : null
104
+ const issueTag = issueNum ? ` #${issueNum}` : ''
105
+ const scope = inferScope(task)
106
+
107
+ const candidateFiles = inferCandidateFiles(task, scope)
108
+ if (Array.isArray(options.additionalFiles)) {
109
+ for (const f of options.additionalFiles) {
110
+ if (typeof f === 'string' && f.trim() !== '') candidateFiles.push(f.trim())
111
+ }
112
+ }
113
+
114
+ const items = [
115
+ `1. Preflight: inspect requirements and existing files for ${title}`,
116
+ `2. Domain logic: implement core algorithms and validation rules`,
117
+ `3. Data layer: update schema, storage methods and routes if needed`,
118
+ `4. Integration & UI: connect route handlers, client views, and translations`,
119
+ `5. Verification: run automated test suite and ensure zero regressions`,
120
+ ]
121
+
122
+ const specLines = [
123
+ `# Technical Specification: ${title}${issueTag}`,
124
+ '',
125
+ '## 1. Problem Statement & Context',
126
+ body ? body : 'No detailed description provided. Analyze issue requirements from title.',
127
+ '',
128
+ '## 2. Target Components & Candidate Files',
129
+ ...candidateFiles.map((f) => `- \`${f}\``),
130
+ '',
131
+ '## 3. Step-by-Step Implementation Algorithm',
132
+ ...items.map((it) => it),
133
+ '',
134
+ '## 4. Edge Cases & Validation Rules',
135
+ '- Validate non-empty inputs and fail-closed error boundaries.',
136
+ '- Ensure backward compatibility with existing schemas and configs.',
137
+ '- Keep all handwritten files under 600 lines with zero Cyrillic literals in lib/.',
138
+ '- Maintain client bundle size strictly under 256,000 bytes limit.',
139
+ '',
140
+ '## 5. Verification Plan',
141
+ '- Unit test coverage covering happy paths and edge cases.',
142
+ '- Full test suite execution: npm test.',
143
+ '- Runtime smoke check on live server instance.',
144
+ ]
145
+
146
+ if (typeof options.customInstructions === 'string' && options.customInstructions.trim() !== '') {
147
+ specLines.push('', '## 6. Special Instructions', options.customInstructions.trim())
148
+ }
149
+
150
+ const spec = specLines.join('\n')
151
+ const summary = `Spec generated for ${title}${issueTag}: ${candidateFiles.length} files targeted, ${items.length} plan steps.`
152
+
153
+ return {
154
+ spec,
155
+ items: items.map(cleanItemText).slice(0, MAX_ITEMS),
156
+ summary,
157
+ scope,
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Extract step-by-step items from markdown text.
163
+ *
164
+ * @param {string} markdown Raw markdown text
165
+ * @returns {string[]} List of extracted plan items
166
+ */
167
+ export function extractPlanItems(markdown) {
168
+ if (typeof markdown !== 'string' || !markdown.trim()) return []
169
+ const lines = markdown.split('\n')
170
+ const items = []
171
+ let inAlgorithmSection = false
172
+
173
+ for (const line of lines) {
174
+ const trimmed = line.trim()
175
+ if (trimmed.startsWith('#')) {
176
+ const lower = trimmed.toLowerCase()
177
+ inAlgorithmSection = lower.includes('algorithm') || lower.includes('step') || lower.includes('plan')
178
+ continue
179
+ }
180
+
181
+ if (inAlgorithmSection || !items.length) {
182
+ const hasPrefix = /^(?:\d+\.|\-|\*|\[[\sxX]\])/.test(trimmed)
183
+ const match = trimmed.match(/^(?:(?:\d+\.|\-|\*)\s+)?(?:\[[\sxX]\]\s+)?(.+)$/)
184
+ if (hasPrefix && match && match[1]) {
185
+ const item = cleanItemText(match[1])
186
+ if (item) items.push(item)
187
+ }
188
+ }
189
+ }
190
+
191
+ return items.slice(0, MAX_ITEMS)
192
+ }
193
+
194
+ /**
195
+ * Parse markdown specification into structured representation.
196
+ *
197
+ * @param {string} markdown Raw markdown spec
198
+ * @returns {{ spec: string, items: string[] }}
199
+ */
200
+ export function parseSpecMarkdown(markdown) {
201
+ const spec = typeof markdown === 'string' ? markdown.trim() : ''
202
+ const items = extractPlanItems(spec)
203
+ return { spec, items }
204
+ }
205
+
206
+ /**
207
+ * Format technical specification and plan into prompt string for agent.
208
+ *
209
+ * @param {object} task Task object
210
+ * @returns {string} Formatted markdown prompt section or empty string
211
+ */
212
+ export function formatPlanPrompt(task) {
213
+ if (!task) return ''
214
+ const spec = typeof task.spec === 'string' ? task.spec.trim() : ''
215
+ const items = Array.isArray(task.plan?.items) ? task.plan.items : []
216
+
217
+ if (spec) {
218
+ return `### Approved Technical Specification & Implementation Plan:\n\n${spec}`
219
+ }
220
+
221
+ if (items.length > 0) {
222
+ return `### Approved Implementation Plan Steps:\n\n${items.map((it, idx) => `${idx + 1}. ${it}`).join('\n')}`
223
+ }
224
+
225
+ return ''
226
+ }
@@ -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
+ }
@@ -1,4 +1,7 @@
1
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'
2
5
 
3
6
  /**
4
7
  * Extended actions for task route handlers (comments, reports, checklist, triage, subtasks, PR, nudge).
@@ -251,7 +254,7 @@ export async function handleCreateTaskPr({ store, gitea, task, input }) {
251
254
  if (!head) return { status: 400, body: { error: 'branch-required' } }
252
255
  const base = String(input?.base || 'main').trim()
253
256
  const title = String(input?.title || task.title || `PR: ${task.title}`).trim()
254
- 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()
255
258
 
256
259
  try {
257
260
  const pr = await gitea.createPullRequest({
@@ -416,3 +419,86 @@ export async function handleTaskRollbackSnapshot({ ctx, store, id, body, config,
416
419
  },
417
420
  }
418
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
+
package/lib/routes.js CHANGED
@@ -71,4 +71,6 @@ export {
71
71
  handleTaskNudge,
72
72
  handleTaskSnapshot,
73
73
  handleTaskRollbackSnapshot,
74
+ handleTaskGeneratePlan,
75
+ handleTaskPrDescription,
74
76
  } from './routes/task-actions.js'