@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
|
@@ -30,12 +30,13 @@ 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'
|
|
37
37
|
import { checkWorktreeDirty } from '../worktree.js'
|
|
38
38
|
import { refreshTask } from '../import.js'
|
|
39
|
+
import { findDuplicates } from '../similarity.js'
|
|
39
40
|
import { parseProgressDump, formatProgressDump } from '../progress-dump.js'
|
|
40
41
|
import { rootOf } from '../config.js'
|
|
41
42
|
import { SessionId } from '@deepseek-ai/dsh-session'
|
|
@@ -67,6 +68,20 @@ export function createTaskRouteHandler(deps) {
|
|
|
67
68
|
return async (req, res) => {
|
|
68
69
|
const pathname = urlOf(req).pathname
|
|
69
70
|
|
|
71
|
+
if (pathname === '/dsh-kanban/task/find-duplicates') {
|
|
72
|
+
if (req.method !== 'POST') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
|
|
73
|
+
if (rejectUntrusted(req, res)) return
|
|
74
|
+
try {
|
|
75
|
+
const input = await readBody(req)
|
|
76
|
+
const candidates = store.listTasks(input?.board || 'main') || []
|
|
77
|
+
const duplicates = findDuplicates(input, candidates, { threshold: input?.threshold, limit: input?.limit })
|
|
78
|
+
writeJson(res, 200, { ok: true, duplicates })
|
|
79
|
+
} catch {
|
|
80
|
+
writeJson(res, 400, { error: 'bad-request' })
|
|
81
|
+
}
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
|
|
70
85
|
// Path without ID handles local task creation.
|
|
71
86
|
if (pathname === '/dsh-kanban/task' || pathname === '/dsh-kanban/task/') {
|
|
72
87
|
if (req.method !== 'POST') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
|
|
@@ -439,6 +454,18 @@ export function createTaskRouteHandler(deps) {
|
|
|
439
454
|
writeJson(res, resObj.status, resObj.body)
|
|
440
455
|
return
|
|
441
456
|
}
|
|
457
|
+
if (req.method === 'POST' && action === 'generate-plan') {
|
|
458
|
+
const resObj = await handleTaskGeneratePlan({ store, id, body: input, eventHub })
|
|
459
|
+
writeJson(res, resObj.status, resObj.body)
|
|
460
|
+
return
|
|
461
|
+
}
|
|
462
|
+
if ((req.method === 'GET' || req.method === 'POST') && action === 'pr-description') {
|
|
463
|
+
const cfg = getConfig()
|
|
464
|
+
const gitRunner = cfg.remoteMode ? createRemoteGitRunner(cfg) : undefined
|
|
465
|
+
const resObj = await handleTaskPrDescription({ store, id, method: req.method, body: input, gitRunner })
|
|
466
|
+
writeJson(res, resObj.status, resObj.body)
|
|
467
|
+
return
|
|
468
|
+
}
|
|
442
469
|
if (req.method === 'POST' && action === 'resume') {
|
|
443
470
|
watchdog?.resumeTask?.({ store, taskId: id, eventHub })
|
|
444
471
|
const task = store.getTask(id)
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text Similarity & Duplicate Task Detection Engine (#380)
|
|
3
|
+
*
|
|
4
|
+
* Implements multilingual tokenization and character n-gram Dice/Jaccard
|
|
5
|
+
* similarity algorithms to identify duplicate or highly similar tasks
|
|
6
|
+
* without external dependencies.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const STOP_WORDS = new Set([
|
|
10
|
+
'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for',
|
|
11
|
+
'of', 'with', 'by', 'from', 'up', 'about', 'into', 'over', 'after',
|
|
12
|
+
'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had',
|
|
13
|
+
'do', 'does', 'did', 'can', 'could', 'should', 'would', 'will', 'shall',
|
|
14
|
+
'this', 'that', 'these', 'those', 'it', 'its', 'as', 'if', 'when',
|
|
15
|
+
'feature', 'bug', 'task', 'fix', 'add', 'update', 'implement', 'test', 'tests'
|
|
16
|
+
])
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Normalizes input text by removing markdown syntax, code fences,
|
|
20
|
+
* URLs, issue references, and non-letter symbols.
|
|
21
|
+
*
|
|
22
|
+
* @param {string} text
|
|
23
|
+
* @returns {string}
|
|
24
|
+
*/
|
|
25
|
+
function normalizeText(text) {
|
|
26
|
+
if (typeof text !== 'string') return ''
|
|
27
|
+
return text
|
|
28
|
+
.toLowerCase()
|
|
29
|
+
.replace(/```[\s\S]*?```/g, ' ')
|
|
30
|
+
.replace(/`/g, ' ')
|
|
31
|
+
.replace(/https?:\/\/[^\s]+/g, ' ')
|
|
32
|
+
.replace(/#\d+/g, ' ')
|
|
33
|
+
.replace(/[^\p{L}\p{N}\s]/gu, ' ')
|
|
34
|
+
.replace(/\s+/g, ' ')
|
|
35
|
+
.trim()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Splits text into cleaned words, filtering out short tokens and common stop words.
|
|
40
|
+
*
|
|
41
|
+
* @param {string} text
|
|
42
|
+
* @returns {string[]}
|
|
43
|
+
*/
|
|
44
|
+
function tokenizeWords(text) {
|
|
45
|
+
const normalized = normalizeText(text)
|
|
46
|
+
if (!normalized) return []
|
|
47
|
+
return normalized
|
|
48
|
+
.split(' ')
|
|
49
|
+
.filter((w) => w.length > 1 && !STOP_WORDS.has(w))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Extracts character n-grams from normalized text for sub-word multilingual matching.
|
|
54
|
+
*
|
|
55
|
+
* @param {string} text
|
|
56
|
+
* @param {number} [n=3]
|
|
57
|
+
* @returns {Set<string>}
|
|
58
|
+
*/
|
|
59
|
+
function extractNgrams(text, n = 3) {
|
|
60
|
+
const normalized = normalizeText(text).replace(/\s+/g, '')
|
|
61
|
+
const ngrams = new Set()
|
|
62
|
+
if (normalized.length < n) {
|
|
63
|
+
if (normalized.length > 0) ngrams.add(normalized)
|
|
64
|
+
return ngrams
|
|
65
|
+
}
|
|
66
|
+
for (let i = 0; i <= normalized.length - n; i++) {
|
|
67
|
+
ngrams.add(normalized.slice(i, i + n))
|
|
68
|
+
}
|
|
69
|
+
return ngrams
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Calculates Dice similarity coefficient between two sets: 2 * |A ∩ B| / (|A| + |B|).
|
|
74
|
+
*
|
|
75
|
+
* @param {Set<string>} setA
|
|
76
|
+
* @param {Set<string>} setB
|
|
77
|
+
* @returns {number} Float between 0.0 and 1.0
|
|
78
|
+
*/
|
|
79
|
+
function diceCoefficient(setA, setB) {
|
|
80
|
+
if (!setA.size || !setB.size) return 0
|
|
81
|
+
let intersection = 0
|
|
82
|
+
for (const item of setA) {
|
|
83
|
+
if (setB.has(item)) intersection++
|
|
84
|
+
}
|
|
85
|
+
return (2 * intersection) / (setA.size + setB.size)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Calculates Jaccard index between two sets: |A ∩ B| / |A ∪ B|.
|
|
90
|
+
*
|
|
91
|
+
* @param {Set<string>} setA
|
|
92
|
+
* @param {Set<string>} setB
|
|
93
|
+
* @returns {number} Float between 0.0 and 1.0
|
|
94
|
+
*/
|
|
95
|
+
function jaccardIndex(setA, setB) {
|
|
96
|
+
if (!setA.size || !setB.size) return 0
|
|
97
|
+
let intersection = 0
|
|
98
|
+
for (const item of setA) {
|
|
99
|
+
if (setB.has(item)) intersection++
|
|
100
|
+
}
|
|
101
|
+
const union = setA.size + setB.size - intersection
|
|
102
|
+
return union > 0 ? intersection / union : 0
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Calculates hybrid text similarity blending word token overlap and character 3-grams.
|
|
107
|
+
*
|
|
108
|
+
* @param {string} a
|
|
109
|
+
* @param {string} b
|
|
110
|
+
* @returns {number} Similarity score between 0.0 and 1.0
|
|
111
|
+
*/
|
|
112
|
+
function calculateTextSimilarity(a, b) {
|
|
113
|
+
if (!a || !b) return 0
|
|
114
|
+
if (a.trim().toLowerCase() === b.trim().toLowerCase()) return 1.0
|
|
115
|
+
|
|
116
|
+
const wordsA = new Set(tokenizeWords(a))
|
|
117
|
+
const wordsB = new Set(tokenizeWords(b))
|
|
118
|
+
const wordScore = diceCoefficient(wordsA, wordsB)
|
|
119
|
+
|
|
120
|
+
const ngramsA = extractNgrams(a, 3)
|
|
121
|
+
const ngramsB = extractNgrams(b, 3)
|
|
122
|
+
const ngramScore = diceCoefficient(ngramsA, ngramsB)
|
|
123
|
+
|
|
124
|
+
// Blend word tokens (0.4) and character n-grams (0.6) for morphological tolerance
|
|
125
|
+
return wordScore * 0.4 + ngramScore * 0.6
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Calculates composite similarity between query task parameters and an existing task card.
|
|
130
|
+
*
|
|
131
|
+
* @param {{ title?: string, description?: string }} query
|
|
132
|
+
* @param {{ title?: string, description?: string }} candidate
|
|
133
|
+
* @returns {number} Score between 0.0 and 1.0
|
|
134
|
+
*/
|
|
135
|
+
function compareTasks(query, candidate) {
|
|
136
|
+
if (!query || !candidate) return 0
|
|
137
|
+
const titleA = query.title || ''
|
|
138
|
+
const titleB = candidate.title || ''
|
|
139
|
+
|
|
140
|
+
if (titleA && titleB && titleA.trim().toLowerCase() === titleB.trim().toLowerCase()) {
|
|
141
|
+
return 1.0
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const titleSim = calculateTextSimilarity(titleA, titleB)
|
|
145
|
+
const descA = query.description || ''
|
|
146
|
+
const descB = candidate.description || ''
|
|
147
|
+
|
|
148
|
+
if (!descA || !descB) {
|
|
149
|
+
return titleSim
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const descSim = calculateTextSimilarity(descA, descB)
|
|
153
|
+
// Title carries 70% weight, description carries 30% weight
|
|
154
|
+
return titleSim * 0.7 + descSim * 0.3
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Finds duplicate or highly similar tasks from an array of candidate cards.
|
|
159
|
+
*
|
|
160
|
+
* @param {{ title?: string, description?: string, excludeId?: string }} query
|
|
161
|
+
* @param {Array<Object>} candidates
|
|
162
|
+
* @param {{ threshold?: number, limit?: number }} [options]
|
|
163
|
+
* @returns {Array<{ id: string, title: string, issueNumber?: number, score: number, percent: number }>}
|
|
164
|
+
*/
|
|
165
|
+
function findDuplicates(query, candidates, options = {}) {
|
|
166
|
+
if (!query || !Array.isArray(candidates) || candidates.length === 0) {
|
|
167
|
+
return []
|
|
168
|
+
}
|
|
169
|
+
const threshold = typeof options.threshold === 'number' ? options.threshold : 0.45
|
|
170
|
+
const limit = typeof options.limit === 'number' ? options.limit : 5
|
|
171
|
+
const excludeId = query.excludeId || query.id
|
|
172
|
+
|
|
173
|
+
const results = []
|
|
174
|
+
for (const candidate of candidates) {
|
|
175
|
+
if (!candidate || !candidate.id) continue
|
|
176
|
+
if (excludeId && candidate.id === excludeId) continue
|
|
177
|
+
|
|
178
|
+
const score = compareTasks(query, candidate)
|
|
179
|
+
if (score >= threshold) {
|
|
180
|
+
results.push({
|
|
181
|
+
id: candidate.id,
|
|
182
|
+
title: candidate.title,
|
|
183
|
+
issueNumber: candidate.issueNumber,
|
|
184
|
+
repo: candidate.repo,
|
|
185
|
+
column: candidate.column,
|
|
186
|
+
score: Math.round(score * 1000) / 1000,
|
|
187
|
+
percent: Math.round(score * 100),
|
|
188
|
+
})
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return results
|
|
193
|
+
.sort((a, b) => b.score - a.score)
|
|
194
|
+
.slice(0, limit)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export {
|
|
198
|
+
normalizeText,
|
|
199
|
+
tokenizeWords,
|
|
200
|
+
extractNgrams,
|
|
201
|
+
diceCoefficient,
|
|
202
|
+
jaccardIndex,
|
|
203
|
+
calculateTextSimilarity,
|
|
204
|
+
compareTasks,
|
|
205
|
+
findDuplicates,
|
|
206
|
+
}
|
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.38",
|
|
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",
|