@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 +100 -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 +13 -1
- package/lib/store.js +12 -3
- package/package.json +1 -1
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
|
@@ -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 === '
|
|
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.
|
|
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",
|