@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/client.js +2809 -2617
- package/lib/config.js +4 -0
- package/lib/launcher.js +4 -0
- package/lib/plan-generator.js +226 -0
- package/lib/pr-generator.js +270 -0
- package/lib/routes/task-actions.js +87 -1
- package/lib/routes.js +2 -0
- package/lib/server/task-handler.js +28 -1
- package/lib/similarity.js +206 -0
- package/lib/store.js +12 -3
- package/package.json +1 -1
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
|
+
|