@goodandready/dsh-kanban 0.2.35 → 0.2.37
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/board-tool.js +8 -0
- package/lib/client.js +3884 -3567
- package/lib/config.js +4 -0
- package/lib/dependencies.js +175 -0
- package/lib/launcher.js +4 -0
- package/lib/plan-generator.js +226 -0
- package/lib/pr-generator.js +270 -0
- package/lib/routes/board.js +4 -0
- package/lib/routes/task-actions.js +160 -1
- package/lib/routes/task.js +27 -1
- package/lib/routes.js +4 -0
- package/lib/server/task-handler.js +51 -0
- package/lib/store.js +25 -2
- package/lib/time-machine.js +214 -0
- package/package.json +1 -1
package/lib/config.js
CHANGED
|
@@ -94,6 +94,8 @@ export const CONFIG_DEFAULTS = {
|
|
|
94
94
|
watchdogEnabled: true,
|
|
95
95
|
watchdogStallTimeoutMin: 15,
|
|
96
96
|
watchdogMaxConsecutiveErrors: 5,
|
|
97
|
+
timeMachineEnabled: true,
|
|
98
|
+
timeMachineAutoSnapshot: true,
|
|
97
99
|
}
|
|
98
100
|
|
|
99
101
|
/** Field descriptions reused in schema and settings card. */
|
|
@@ -147,6 +149,8 @@ export const CONFIG_HINTS = {
|
|
|
147
149
|
watchdogEnabled: 'Enable background watchdog timer and circuit breaker for stalled agent tasks.',
|
|
148
150
|
watchdogStallTimeoutMin: 'Minutes of inactivity before active task is marked stalled by watchdog (default: 15).',
|
|
149
151
|
watchdogMaxConsecutiveErrors: 'Number of consecutive execution errors before tripping circuit breaker (default: 5).',
|
|
152
|
+
timeMachineEnabled: 'Enable integration with @goodandready/dsh-time-machine for task checkpoints and rollback.',
|
|
153
|
+
timeMachineAutoSnapshot: 'Automatically create inception snapshot when task transitions to in-progress.',
|
|
150
154
|
}
|
|
151
155
|
|
|
152
156
|
/** Setting key for column WIP limit; not all columns have limits. */
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Directed Acyclic Graph (DAG) task dependency validation and auto-unblocking logic.
|
|
2
|
+
// Enforces that tasks with unresolved blockers cannot transition to in-progress.
|
|
3
|
+
// Cycle detection prevents circular dependencies (e.g. A -> B -> A).
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Check if targetId is reachable from startId following blockedBy links.
|
|
7
|
+
*
|
|
8
|
+
* @param {string} startId starting task identifier
|
|
9
|
+
* @param {string} targetId target task identifier
|
|
10
|
+
* @param {Function|object} getTaskFn task getter function or store
|
|
11
|
+
* @param {Set<string>} [visited] visited set to break cycles during traversal
|
|
12
|
+
* @returns {boolean} true if targetId is reachable
|
|
13
|
+
*/
|
|
14
|
+
function canReach(startId, targetId, getTaskFn, visited = new Set()) {
|
|
15
|
+
if (startId === targetId) return true
|
|
16
|
+
if (visited.has(startId)) return false
|
|
17
|
+
visited.add(startId)
|
|
18
|
+
|
|
19
|
+
const task = typeof getTaskFn === 'function' ? getTaskFn(startId) : getTaskFn?.getTask?.(startId)
|
|
20
|
+
if (!task) return false
|
|
21
|
+
|
|
22
|
+
const blockers = Array.isArray(task.blockedBy) ? task.blockedBy : []
|
|
23
|
+
for (const nextId of blockers) {
|
|
24
|
+
if (typeof nextId === 'string' && nextId.trim() !== '') {
|
|
25
|
+
if (canReach(nextId, targetId, getTaskFn, visited)) return true
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return false
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Validate proposed dependencies for a task against cycles and self-references.
|
|
33
|
+
*
|
|
34
|
+
* @param {string} taskId task identifier
|
|
35
|
+
* @param {string[]} newBlockedBy proposed array of blocking task IDs
|
|
36
|
+
* @param {Function|object} getTaskFn task getter function or store
|
|
37
|
+
* @returns {{valid: boolean, error?: string}}
|
|
38
|
+
*/
|
|
39
|
+
export function validateDependencies(taskId, newBlockedBy, getTaskFn) {
|
|
40
|
+
if (!Array.isArray(newBlockedBy)) {
|
|
41
|
+
return { valid: true }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 1. Check self-dependency
|
|
45
|
+
if (newBlockedBy.includes(taskId)) {
|
|
46
|
+
return {
|
|
47
|
+
valid: false,
|
|
48
|
+
error: `Self-dependency: task "${taskId}" cannot depend on itself.`,
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 2. Check for circular dependency
|
|
53
|
+
for (const blockerId of newBlockedBy) {
|
|
54
|
+
if (typeof blockerId !== 'string' || blockerId.trim() === '') continue
|
|
55
|
+
if (canReach(blockerId, taskId, getTaskFn)) {
|
|
56
|
+
return {
|
|
57
|
+
valid: false,
|
|
58
|
+
error: `Circular dependency detected: task "${blockerId}" already depends on "${taskId}".`,
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { valid: true }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Compute reverse mapping (blocks) from blockedBy relations across tasks.
|
|
68
|
+
*
|
|
69
|
+
* @param {Array<object>} tasks list of all tasks
|
|
70
|
+
* @returns {Record<string, string[]>} mapping of taskId to list of tasks it blocks
|
|
71
|
+
*/
|
|
72
|
+
export function computeBlocksMap(tasks) {
|
|
73
|
+
const blocks = {}
|
|
74
|
+
if (!Array.isArray(tasks)) return blocks
|
|
75
|
+
|
|
76
|
+
for (const task of tasks) {
|
|
77
|
+
if (task && typeof task.id === 'string') {
|
|
78
|
+
blocks[task.id] = []
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
for (const task of tasks) {
|
|
83
|
+
if (!task || typeof task.id !== 'string') continue
|
|
84
|
+
const blockedBy = Array.isArray(task.blockedBy) ? task.blockedBy : []
|
|
85
|
+
for (const bId of blockedBy) {
|
|
86
|
+
if (typeof bId === 'string' && bId.trim() !== '') {
|
|
87
|
+
if (!blocks[bId]) blocks[bId] = []
|
|
88
|
+
if (!blocks[bId].includes(task.id)) {
|
|
89
|
+
blocks[bId].push(task.id)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return blocks
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Check whether a task is blocked by unfinished tasks.
|
|
100
|
+
*
|
|
101
|
+
* @param {object} task task object
|
|
102
|
+
* @param {Function|object} getTaskFn task getter function or store
|
|
103
|
+
* @returns {{isBlocked: boolean, pendingBlockers: Array<object>}}
|
|
104
|
+
*/
|
|
105
|
+
export function checkTaskBlocked(task, getTaskFn) {
|
|
106
|
+
const blockedBy = Array.isArray(task?.blockedBy) ? task.blockedBy : []
|
|
107
|
+
if (blockedBy.length === 0) {
|
|
108
|
+
return { isBlocked: false, pendingBlockers: [] }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const pending = []
|
|
112
|
+
for (const bId of blockedBy) {
|
|
113
|
+
if (typeof bId !== 'string' || bId.trim() === '') continue
|
|
114
|
+
const bTask = typeof getTaskFn === 'function' ? getTaskFn(bId) : getTaskFn?.getTask?.(bId)
|
|
115
|
+
if (bTask && bTask.column !== 'done') {
|
|
116
|
+
pending.push(bTask)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
isBlocked: pending.length > 0,
|
|
122
|
+
pendingBlockers: pending,
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Check if moving a task to a target column is allowed under dependency constraints.
|
|
128
|
+
*
|
|
129
|
+
* @param {object} task task object
|
|
130
|
+
* @param {string} targetColumn target column
|
|
131
|
+
* @param {Function|object} getTaskFn task getter function or store
|
|
132
|
+
* @returns {{allowed: boolean, reason?: string, pendingBlockers?: Array<object>}}
|
|
133
|
+
*/
|
|
134
|
+
export function checkMoveAllowed(task, targetColumn, getTaskFn) {
|
|
135
|
+
if (targetColumn === 'in-progress') {
|
|
136
|
+
const check = checkTaskBlocked(task, getTaskFn)
|
|
137
|
+
if (check.isBlocked) {
|
|
138
|
+
const names = check.pendingBlockers.map((b) => b.title || b.id).join(', ')
|
|
139
|
+
return {
|
|
140
|
+
allowed: false,
|
|
141
|
+
reason: `Cannot move card to in-progress: task is blocked by unfinished tasks (${names}). All blockers must be in done before starting.`,
|
|
142
|
+
pendingBlockers: check.pendingBlockers,
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return { allowed: true }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Find all tasks that were blocked by completedTaskId and are now unblocked.
|
|
151
|
+
*
|
|
152
|
+
* @param {string} completedTaskId ID of task moving to done
|
|
153
|
+
* @param {Function|object} getTaskFn task getter function or store
|
|
154
|
+
* @param {Function} listTasksFn function returning all tasks
|
|
155
|
+
* @returns {Array<object>} tasks that transitioned from blocked to unblocked
|
|
156
|
+
*/
|
|
157
|
+
export function findUnblockedDependents(completedTaskId, getTaskFn, listTasksFn) {
|
|
158
|
+
const allTasks = typeof listTasksFn === 'function' ? listTasksFn() : []
|
|
159
|
+
const unblocked = []
|
|
160
|
+
|
|
161
|
+
for (const task of allTasks) {
|
|
162
|
+
if (!task || task.column === 'done') continue
|
|
163
|
+
const blockedBy = Array.isArray(task.blockedBy) ? task.blockedBy : []
|
|
164
|
+
if (!blockedBy.includes(completedTaskId)) continue
|
|
165
|
+
|
|
166
|
+
// Check if task is still blocked by any other unfinished task
|
|
167
|
+
const check = checkTaskBlocked(task, getTaskFn)
|
|
168
|
+
if (!check.isBlocked) {
|
|
169
|
+
unblocked.push(task)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return unblocked
|
|
174
|
+
}
|
|
175
|
+
|
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
|
+
}
|