@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/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
+ }