@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.
@@ -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 === 'labels') values.push(JSON.stringify(patch[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.36",
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",