@goodandready/dsh-kanban 0.2.36 → 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.
package/lib/client.js CHANGED
@@ -1024,6 +1024,19 @@ window.__ModuleLoader__.load({
1024
1024
  'tm.rollbackSuccess': 'Task worktree successfully rolled back to inception snapshot!',
1025
1025
  'tm.rollbackFailed': 'Rollback failed: ',
1026
1026
  'tm.notice': 'Snapshots and rollback require @goodandready/dsh-time-machine plugin in DSH profile.',
1027
+ 'spec.title': 'Technical Specification & Plan',
1028
+ 'spec.generate': 'Generate Plan',
1029
+ 'spec.generating': 'Generating…',
1030
+ 'spec.generated': 'Plan generated successfully!',
1031
+ 'spec.failed': 'Failed to generate plan: ',
1032
+ 'spec.edit': 'Edit Spec',
1033
+ 'spec.save': 'Save Spec',
1034
+ 'spec.saved': 'Specification saved!',
1035
+ 'pr.title': 'Pull Request & Release Notes',
1036
+ 'pr.generate': 'Generate PR',
1037
+ 'pr.copy': 'Copy Markdown',
1038
+ 'pr.copied': 'Copied to clipboard!',
1039
+ 'pr.failed': 'Failed to generate PR: ',
1027
1040
  }
1028
1041
 
1029
1042
  const zh = {
@@ -1504,6 +1517,19 @@ window.__ModuleLoader__.load({
1504
1517
  'tm.rollbackSuccess': '工作树已成功回滚至初始快照!',
1505
1518
  'tm.rollbackFailed': '回滚失败:',
1506
1519
  'tm.notice': '快照与回滚功能需要 DSH Profile 中安装 @goodandready/dsh-time-machine 插件。',
1520
+ 'spec.title': '技术规格与计划',
1521
+ 'spec.generate': '生成计划',
1522
+ 'spec.generating': '生成中…',
1523
+ 'spec.generated': '计划已生成!',
1524
+ 'spec.failed': '生成计划失败:',
1525
+ 'spec.edit': '编辑规格',
1526
+ 'spec.save': '保存规格',
1527
+ 'spec.saved': '规格已保存!',
1528
+ 'pr.title': 'Pull Request 与发布说明',
1529
+ 'pr.generate': '生成 PR',
1530
+ 'pr.copy': '复制 Markdown',
1531
+ 'pr.copied': '已复制到剪贴板!',
1532
+ 'pr.failed': '生成 PR 失败:',
1507
1533
  }
1508
1534
 
1509
1535
  function fallbackT(key, vars) {
@@ -2244,6 +2270,49 @@ window.__ModuleLoader__.load({
2244
2270
  const [newCommentText, setNewCommentText] = React.useState('')
2245
2271
  const [quickFilter, setQuickFilter] = React.useState('all')
2246
2272
  const [subtasks, setSubtasks] = React.useState([])
2273
+ const [specEdit, setSpecEdit] = React.useState(false)
2274
+ const [specText, setSpecText] = React.useState('')
2275
+ const [busySpec, setBusySpec] = React.useState(false)
2276
+ const [busyPr, setBusyPr] = React.useState(false)
2277
+ const runTaskAction = async (path, body, okMsg) => {
2278
+ const res = await api(path, { method: 'POST', body: JSON.stringify(body || {}) })
2279
+ if (res?.task) {
2280
+ setBoard((b) => b ? { ...b, tasks: (b.tasks || []).map((x) => x.id === res.task.id ? res.task : x) } : b)
2281
+ setOpenTask(res.task)
2282
+ }
2283
+ if (okMsg) showNotice(okMsg)
2284
+ return res
2285
+ }
2286
+ const onGenPlan = async () => {
2287
+ if (!openTask) return
2288
+ setBusySpec(true)
2289
+ try { await runTaskAction(`/dsh-kanban/task/${openTask.id}/generate-plan`, { board: currentBoard }, t('spec.generated')) }
2290
+ catch (e) { showNotice(t('spec.failed') + (e?.message || e), 'error') }
2291
+ finally { setBusySpec(false) }
2292
+ }
2293
+ const onSaveSpec = async () => {
2294
+ if (!openTask) return
2295
+ try {
2296
+ await runTaskAction(`/dsh-kanban/task/${openTask.id}`, { spec: specText }, t('spec.saved'))
2297
+ setSpecEdit(false)
2298
+ } catch (e) { showNotice('Save failed: ' + (e?.message || e), 'error') }
2299
+ }
2300
+ const onGenPr = async () => {
2301
+ if (!openTask) return
2302
+ setBusyPr(true)
2303
+ try { await runTaskAction(`/dsh-kanban/task/${openTask.id}/pr-description`, { board: currentBoard }, t('pr.created')) }
2304
+ catch (e) { showNotice(t('pr.failed') + (e?.message || e), 'error') }
2305
+ finally { setBusyPr(false) }
2306
+ }
2307
+ const onCreatePrGitea = async () => {
2308
+ if (!openTask) return
2309
+ setBusyPr(true)
2310
+ try {
2311
+ const res = await runTaskAction(`/dsh-kanban/task/${openTask.id}/create-pr`, { board: currentBoard })
2312
+ showNotice(res?.prUrl ? 'PR created: ' + res.prUrl : t('pr.created'))
2313
+ } catch (e) { showNotice(t('pr.failed') + (e?.message || e), 'error') }
2314
+ finally { setBusyPr(false) }
2315
+ }
2247
2316
  const [triageOpen, setTriageOpen] = React.useState(false)
2248
2317
  const [triageText, setTriageText] = React.useState('')
2249
2318
  const [triageGitea, setTriageGitea] = React.useState(false)
@@ -3203,6 +3272,37 @@ window.__ModuleLoader__.load({
3203
3272
  : null
3204
3273
  )
3205
3274
  ),
3275
+ h('div', { className: 'dkb-section-card', style: { margin: '8px 0' } },
3276
+ h('div', { className: 'dkb-section-title' },
3277
+ h('span', null, '📋 ' + (t('spec.title') || 'Technical Specification & Plan')),
3278
+ openTask.spec ? h('span', { className: 'dkb-section-badge dkb-section-badge-ok' }, 'Spec Ready') : null
3279
+ ),
3280
+ openTask.spec && !specEdit
3281
+ ? h('pre', { className: 'dkb-consolePre', style: { maxHeight: 160 } }, openTask.spec)
3282
+ : (specEdit ? h('textarea', { className: 'dkb-area', value: specText, onChange: (e) => setSpecText(e.target.value) }) : null),
3283
+ h('div', { className: 'dkb-row', style: { marginTop: 4 } },
3284
+ h('button', { type: 'button', className: 'dkb-save', disabled: busySpec, onClick: onGenPlan }, busySpec ? t('spec.generating') : t('spec.generate')),
3285
+ openTask.spec ? h('button', {
3286
+ type: 'button', className: 'dkb-discard',
3287
+ onClick: () => { if (specEdit) onSaveSpec(); else { setSpecText(openTask.spec || ''); setSpecEdit(true) } }
3288
+ }, specEdit ? t('spec.save') : t('spec.edit')) : null
3289
+ )
3290
+ ),
3291
+ h('div', { className: 'dkb-section-card', style: { margin: '8px 0' } },
3292
+ h('div', { className: 'dkb-section-title' },
3293
+ h('span', null, '🔀 ' + (t('pr.title') || 'Pull Request & Release Notes')),
3294
+ openTask.prDescription ? h('span', { className: 'dkb-section-badge dkb-section-badge-ok' }, 'PR Ready') : null
3295
+ ),
3296
+ openTask.prDescription ? h('pre', { className: 'dkb-consolePre', style: { maxHeight: 160 } }, openTask.prDescription) : null,
3297
+ h('div', { className: 'dkb-row', style: { marginTop: 4 } },
3298
+ h('button', { type: 'button', className: 'dkb-save', disabled: busyPr, onClick: onGenPr }, busyPr ? t('pr.generate') : t('pr.generate')),
3299
+ openTask.prDescription ? h('button', {
3300
+ type: 'button', className: 'dkb-discard',
3301
+ onClick: () => { navigator.clipboard?.writeText(openTask.prDescription); showNotice(t('pr.copied')) }
3302
+ }, t('pr.copy')) : null,
3303
+ openTask.branch ? h('button', { type: 'button', className: 'dkb-save', disabled: busyPr, onClick: onCreatePrGitea }, t('pr.create')) : null
3304
+ )
3305
+ ),
3206
3306
  openTask.worktree
3207
3307
  ? h('div', { className: 'dkb-note', style: { wordBreak: 'break-all' } },
3208
3308
  t('panel.worktree', { path: openTask.worktree })
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'
@@ -30,7 +30,7 @@ import {
30
30
  handleToggleChecklistItem,
31
31
  } from '../routes.js'
32
32
  import { verifyAndRecordGate } from '../verification.js'
33
- import { handleTaskSnapshot, handleTaskRollbackSnapshot } from '../routes.js'
33
+ import { handleTaskSnapshot, handleTaskRollbackSnapshot, handleTaskGeneratePlan, handleTaskPrDescription } from '../routes.js'
34
34
  import { createInceptionSnapshot } from '../time-machine.js'
35
35
  import { runTask, queueTask, unqueueTask, resolveModel, buildStartMessage, PERMISSION_RANKS } from '../launcher.js'
36
36
  import { dispatchMove, stopWork, MOVE_DETAIL, STOP_DETAIL } from '../commands.js'
@@ -439,6 +439,18 @@ export function createTaskRouteHandler(deps) {
439
439
  writeJson(res, resObj.status, resObj.body)
440
440
  return
441
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
+ }
442
454
  if (req.method === 'POST' && action === 'resume') {
443
455
  watchdog?.resumeTask?.({ store, taskId: id, eventHub })
444
456
  const task = store.getTask(id)
package/lib/store.js CHANGED
@@ -81,6 +81,8 @@ CREATE TABLE IF NOT EXISTS tasks (
81
81
  stalledAt INTEGER NOT NULL DEFAULT 0,
82
82
  blockedBy TEXT NOT NULL DEFAULT '[]',
83
83
  inceptionSnapshotId TEXT NOT NULL DEFAULT '',
84
+ spec TEXT NOT NULL DEFAULT '',
85
+ prDescription TEXT NOT NULL DEFAULT '',
84
86
  updatedAt INTEGER NOT NULL
85
87
  );
86
88
  CREATE INDEX IF NOT EXISTS tasks_board_col ON tasks(board, col, position);
@@ -116,7 +118,7 @@ const WRITABLE = [
116
118
  'verificationCommand', 'verificationStatus',
117
119
  'costMetrics', 'costLimitExceeded',
118
120
  'stalled', 'stalledReason', 'stalledAt',
119
- 'blockedBy', 'inceptionSnapshotId',
121
+ 'blockedBy', 'inceptionSnapshotId', 'spec', 'prDescription',
120
122
  ]
121
123
 
122
124
  /** Valid transition sources; unexpected values are rejected to preserve log integrity. */
@@ -124,7 +126,7 @@ const SOURCES = new Set(['manual', 'session', 'gitea', 'github', 'tool', 'workfl
124
126
 
125
127
  function rowToTask(row) {
126
128
  if (row === undefined) return undefined
127
- const { col, labels, waiting, labelColors, runs, checklist, report, comments, rejectReason, rejectedAt, isEpic, parentId, stalled, blockedBy, inceptionSnapshotId, ...rest } = row
129
+ const { col, labels, waiting, labelColors, runs, checklist, report, comments, rejectReason, rejectedAt, isEpic, parentId, stalled, blockedBy, inceptionSnapshotId, spec, prDescription, ...rest } = row
128
130
  let parsedChecklist = []
129
131
  try {
130
132
  const value = JSON.parse(checklist ?? '[]')
@@ -187,6 +189,8 @@ function rowToTask(row) {
187
189
  } catch { return [] }
188
190
  })(),
189
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 : ''),
190
194
  }
191
195
  }
192
196
 
@@ -284,6 +288,8 @@ export function openStore({ dir }) {
284
288
  ['stalledAt', 'INTEGER NOT NULL DEFAULT 0'],
285
289
  ['blockedBy', "TEXT NOT NULL DEFAULT '[]'"],
286
290
  ['inceptionSnapshotId', "TEXT NOT NULL DEFAULT ''"],
291
+ ['spec', "TEXT NOT NULL DEFAULT ''"],
292
+ ['prDescription', "TEXT NOT NULL DEFAULT ''"],
287
293
  ]) {
288
294
  try { db.exec(`ALTER TABLE tasks ADD COLUMN ${column} ${definition}`) } catch { /* column already exists */ }
289
295
  }
@@ -614,7 +620,8 @@ export function openStore({ dir }) {
614
620
  for (const key of WRITABLE) {
615
621
  if (!Object.hasOwn(patch, key)) continue
616
622
  fields.push(`${key} = ?`)
617
- 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] ?? []))
618
625
  else if (key === 'labelColors') values.push(JSON.stringify(patch[key] ?? {}))
619
626
  else if (key === 'runs') values.push(typeof patch[key] === 'string' ? patch[key] : JSON.stringify(patch[key] ?? []))
620
627
  else if (key === 'checklist') values.push(typeof patch[key] === 'string' ? patch[key] : JSON.stringify(patch[key] ?? []))
@@ -628,6 +635,8 @@ export function openStore({ dir }) {
628
635
  else if (key === 'parentId') values.push(String(patch[key] ?? ''))
629
636
  else if (key === 'blockedBy') values.push(Array.isArray(patch[key]) ? JSON.stringify(patch[key]) : (typeof patch[key] === 'string' ? patch[key] : '[]'))
630
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] ?? ''))
631
640
  else values.push(patch[key] ?? null)
632
641
  }
633
642
  if (fields.length > 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-kanban",
3
- "version": "0.2.36",
3
+ "version": "0.2.37",
4
4
  "description": "Visual Kanban board for DeepSeek Harness: Gitea and GitHub-backed tasks, workflow columns, and a dedicated agent session per task.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",