@goodandready/dsh-kanban 0.2.20 → 0.2.21

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
@@ -106,7 +106,7 @@ export const CONFIG_HINTS = {
106
106
  preventIdleSleep: 'Prevent host sleep while task agent sessions are running.',
107
107
  progressDumpEnabled: 'Enable PROGRESSDUMP state checkpoints between agent sessions.',
108
108
  permissionGateEnabled: 'Require human confirmation before launching elevated permission tasks.',
109
- sessionDefaultPermission: 'Default permission tier for task sessions (read-only, workspace-write, full).',
109
+ sessionDefaultPermission: 'Default permission tier for task sessions (read-only, workspace-write, danger-full-access).',
110
110
  announceToAgent: 'Post stage change announcement messages directly into task agent session.',
111
111
  maxConcurrentSessions: 'Maximum number of simultaneous active agent sessions on the board.',
112
112
  workspaceClaimBoundaries: 'Enforce workspace ownership boundaries to prevent cross-workspace task execution.',
package/lib/events.js CHANGED
@@ -28,14 +28,26 @@ export class KanbanEventHub {
28
28
  }
29
29
  }
30
30
 
31
- handleSseRequest(req, res) {
32
- res.writeHead(200, {
31
+ handleSseRequest(req, res, { isTrustedRequest } = {}) {
32
+ if (typeof isTrustedRequest === 'function' && !isTrustedRequest(req)) {
33
+ res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' })
34
+ res.end('forbidden')
35
+ return () => {}
36
+ }
37
+
38
+ const headers = {
33
39
  'Content-Type': 'text/event-stream',
34
40
  'Cache-Control': 'no-cache, no-transform',
35
41
  'Connection': 'keep-alive',
36
42
  'X-Accel-Buffering': 'no',
37
- 'Access-Control-Allow-Origin': '*',
38
- })
43
+ }
44
+ const origin = req?.headers?.origin
45
+ if (origin && origin !== 'null') {
46
+ headers['Access-Control-Allow-Origin'] = origin
47
+ headers['Vary'] = 'Origin'
48
+ }
49
+
50
+ res.writeHead(200, headers)
39
51
 
40
52
  res.write(':connected\n\n')
41
53
  this.clients.add(res)
package/lib/gitea.js CHANGED
@@ -36,7 +36,14 @@ export function isCredentialName(value) {
36
36
 
37
37
  /** Check if value looks like a raw token secret rather than a credential reference name. */
38
38
  export function looksLikeSecret(value) {
39
- return /^[A-Za-z0-9_-]{32,}$/.test(String(value || '').trim())
39
+ const str = String(value || '').trim()
40
+ if (str === '') return false
41
+ if (/^(ghp_|gho_|github_pat_|glpat-|bearer\s+|token\s+)/i.test(str)) return true
42
+ if (/^[0-9a-f]{40}$/i.test(str)) return true
43
+ if (/^eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(str)) return true
44
+ // Raw alphanumeric token >= 32 characters without underscores
45
+ if (/^[A-Za-z0-9]{32,}$/.test(str)) return true
46
+ return false
40
47
  }
41
48
 
42
49
  /**
package/lib/github.js CHANGED
@@ -13,12 +13,18 @@ export function normalizeGithubUrl(raw) {
13
13
  return match ? match[0].replace(/\/+$/, '') : DEFAULT_BASE_URL
14
14
  }
15
15
 
16
+ export function safeIssueIndex(value) {
17
+ const n = typeof value === 'number' ? value : Number.parseInt(String(value ?? '').trim(), 10)
18
+ if (!Number.isSafeInteger(n) || n <= 0) return undefined
19
+ if (typeof value === 'string' && String(n) !== value.trim()) return undefined
20
+ return n
21
+ }
22
+
16
23
  export function safeSegment(value) {
17
24
  const s = String(value ?? '').trim()
18
- if (!s || s.includes('/') || s.includes('\\') || s.includes('..') || s.includes('\0')) {
19
- return undefined
20
- }
21
- return s
25
+ if (!s || s === '.' || s === '..') return undefined
26
+ if (!/^[A-Za-z0-9._-]+$/.test(s)) return undefined
27
+ return encodeURIComponent(s)
22
28
  }
23
29
 
24
30
  export function isCredentialName(value) {
@@ -170,14 +176,14 @@ export function createGitHubClient({ getConfig, resolveToken, fetchImpl = fetch,
170
176
 
171
177
  /** Get single issue by number. */
172
178
  async getIssue({ owner, repo, index }) {
173
- const n = safeSegment(index)
179
+ const n = safeIssueIndex(index)
174
180
  if (n === undefined) throw Object.assign(new Error('invalid issue number'), { code: 'bad-segment' })
175
181
  return request('GET', repoPath(owner, repo, `/issues/${n}`))
176
182
  },
177
183
 
178
184
  /** Post comment on issue. */
179
185
  async comment({ owner, repo, index, body }) {
180
- const n = safeSegment(index)
186
+ const n = safeIssueIndex(index)
181
187
  if (n === undefined) throw Object.assign(new Error('invalid issue number'), { code: 'bad-segment' })
182
188
  return request('POST', repoPath(owner, repo, `/issues/${n}/comments`), { body })
183
189
  },
package/lib/index.js CHANGED
@@ -119,6 +119,18 @@ export const SETTINGS_NAMESPACE = 'dsh-kanban'
119
119
  * Scalar values only: client config API only writes scalar fields;
120
120
  * dictionaries or arrays here would make the settings card uneditable.
121
121
  */
122
+ // Ensure .volatile() helper exists on Schemastery schema prototype
123
+ try {
124
+ const schemaProto = Object.getPrototypeOf(z.boolean())
125
+ if (schemaProto && typeof schemaProto.volatile !== 'function') {
126
+ schemaProto.volatile = function () {
127
+ return typeof this.extra === 'function' ? this.extra('volatile', true) : this
128
+ }
129
+ }
130
+ } catch {
131
+ // safe fallback
132
+ }
133
+
122
134
  export const Config = z.object(Object.fromEntries(
123
135
  Object.entries(CONFIG_DEFAULTS).map(([field, fallback]) => {
124
136
  // Node type derived from default value type.
@@ -128,7 +140,8 @@ export const Config = z.object(Object.fromEntries(
128
140
  if (field === 'giteaTokenRef' || field === 'webhookSecretRef' || field === 'githubTokenRef' || field === 'githubWebhookSecretRef') {
129
141
  node = node.role('credential-ref')
130
142
  }
131
- return [field, node.default(fallback).description(CONFIG_HINTS[field] ?? '')]
143
+ const described = node.default(fallback).description(CONFIG_HINTS[field] ?? '')
144
+ return [field, typeof described.volatile === 'function' ? described.volatile() : described]
132
145
  }),
133
146
  ))
134
147
 
@@ -215,13 +228,57 @@ export function apply(ctx, config) {
215
228
  }
216
229
  }
217
230
 
231
+ let settingsSvc = null
232
+
233
+ const readServedConfig = () => {
234
+ if (typeof settingsSvc?.describe !== 'function') return null
235
+ try {
236
+ const row = settingsSvc.describe().find((r) => r?.ns === SETTINGS_NAMESPACE)
237
+ if (!row || row.status !== 'ready' || !row.value) return null
238
+ return withDefaults(row.value)
239
+ } catch (err) {
240
+ ctx.logger?.warn?.(`dsh-kanban: settings describe failed: ${err?.message || err}`)
241
+ return null
242
+ }
243
+ }
244
+
218
245
  ctx.inject(['settings'], (sctx) => {
219
- const scope = sctx.settings.register(SETTINGS_NAMESPACE, Config, { base: effective })
220
- const read = () => withDefaults(scope.get() ?? effective)
221
- effective = read()
222
- ctx.effect(() => scope.watch(() => { effective = read(); rearm() }), 'dsh-kanban: settings watcher')
246
+ try {
247
+ settingsSvc = sctx?.settings ?? sctx?.get?.('settings') ?? null
248
+ const served = readServedConfig()
249
+ if (served) {
250
+ effective = served
251
+ rearm()
252
+ }
253
+ } catch (err) {
254
+ ctx.logger?.warn?.(`dsh-kanban: settings service unavailable: ${err?.message || err}`)
255
+ }
223
256
  })
224
257
 
258
+ // Listen for config and volatile updates from host
259
+ if (typeof ctx?.on === 'function') {
260
+ const applyPatch = (updated) => {
261
+ if (updated && typeof updated === 'object') {
262
+ const incoming = updated[SETTINGS_NAMESPACE] ?? updated
263
+ if (incoming && typeof incoming === 'object') {
264
+ try {
265
+ effective = withDefaults({ ...effective, ...incoming })
266
+ rearm()
267
+ } catch { /* ignore invalid update */ }
268
+ }
269
+ }
270
+ }
271
+ if (typeof ctx.effect === 'function') {
272
+ ctx.effect(() => ctx.on('loader/volatile-update', applyPatch))
273
+ ctx.effect(() => ctx.on('config', applyPatch))
274
+ ctx.effect(() => ctx.on('settings/document-updated', applyPatch))
275
+ } else {
276
+ ctx.on('loader/volatile-update', applyPatch)
277
+ ctx.on('config', applyPatch)
278
+ ctx.on('settings/document-updated', applyPatch)
279
+ }
280
+ }
281
+
225
282
  // Dedicated Gitea client resolves token via core credentials service.
226
283
  // Secret token values are never exposed externally; only the credential reference name is stored.
227
284
  const resolveSecret = async (name) => {
@@ -470,7 +527,7 @@ export function apply(ctx, config) {
470
527
 
471
528
  // Periodic Gitea reconciliation. 0 disables it; overlapping executions are prevented.
472
529
  // Outbound push queue to Gitea. Card moves do not block on network; failures retry in background.
473
- const outbox = createOutbox({ gitea: git, store, logger: ctx.logger })
530
+ const outbox = createOutbox({ gitea: git, get store() { return store }, logger: ctx.logger })
474
531
 
475
532
  const queueOutbound = (taskId, column) => {
476
533
  if (getConfig().pushToGitea !== true) return
@@ -852,7 +909,7 @@ export function apply(ctx, config) {
852
909
  kind: 'exact',
853
910
  path: '/dsh-kanban/events',
854
911
  handler: (req, res) => {
855
- eventHub.handleSseRequest(req, res)
912
+ eventHub.handleSseRequest(req, res, { isTrustedRequest })
856
913
  },
857
914
  }), 'dsh-kanban: sse events stream')
858
915
 
@@ -1174,7 +1231,7 @@ export function apply(ctx, config) {
1174
1231
  agentPreset: input?.agentPreset || task.agentPreset,
1175
1232
  permission: input?.permission || task.permission,
1176
1233
  permissions: ctx.get('permissionPresets'),
1177
- claimAs: req.headers['x-dsh-user'] || (await whoAmI()),
1234
+ claimAs: await whoAmI(),
1178
1235
  workspaceRegistry: ctx.get('workspaceRegistry'),
1179
1236
  resume: true,
1180
1237
  })
package/lib/launcher.js CHANGED
@@ -289,7 +289,9 @@ export async function runTask({
289
289
  if (config?.permissionGateEnabled) {
290
290
  const perm = permission || task.permission || 'read-only'
291
291
  const defaultPerm = config?.sessionDefaultPermission || 'read-only'
292
- if ((PERMISSION_RANKS[perm] ?? 0) > (PERMISSION_RANKS[defaultPerm] ?? 0)) {
292
+ const permRank = PERMISSION_RANKS[perm] ?? Infinity
293
+ const defaultRank = PERMISSION_RANKS[defaultPerm] ?? 0
294
+ if (permRank > defaultRank) {
293
295
  if (!task.permissionConfirmedAt || task.permissionConfirmedAt === 0) {
294
296
  const err = new Error('Permission confirmation required')
295
297
  err.key = 'permission-unconfirmed'
@@ -473,9 +475,11 @@ export async function runBatch({
473
475
  }))
474
476
 
475
477
  if (own !== '' && list.length === 1) {
476
- send(own, 'task-start')
478
+ await send(own, 'task-start')
477
479
  } else {
478
- list.forEach((task, at) => send(buildQueuedMessage(task, config, at + 1, list.length), 'batch-queued'))
480
+ for (let at = 0; at < list.length; at++) {
481
+ await send(buildQueuedMessage(list[at], config, at + 1, list.length), 'batch-queued')
482
+ }
479
483
  }
480
484
 
481
485
  const started = []
package/lib/outbound.js CHANGED
@@ -90,9 +90,10 @@ export function createOutbox({ gitea, store, logger, maxAttempts = 4 }) {
90
90
  dropped += 1
91
91
  logger?.warn?.(`dsh-kanban: Gitea delivery failed permanently: ${error?.message}`)
92
92
  try {
93
- const task = store.getTask(item.taskId)
93
+ const currentStore = typeof store === 'function' ? store() : store
94
+ const task = currentStore?.getTask?.(item.taskId)
94
95
  if (task !== undefined) {
95
- store.addTransition({
96
+ currentStore?.addTransition?.({
96
97
  taskId: item.taskId,
97
98
  fromCol: task.column,
98
99
  toCol: task.column,
package/lib/routes.js CHANGED
@@ -171,6 +171,12 @@ export function buildBoard({ store, config, board = 'main', repo, liveOf, sync,
171
171
  export function applyMove({ store, config, id, column, beforeId, afterId, source = 'manual', detail = '' }) {
172
172
  const before = store.getTask(id)
173
173
  if (before === undefined) return { error: 'task-not-found', status: 404 }
174
+ if (column !== undefined && column !== null) {
175
+ const allowed = columnsOf(store.boardKind(before.board))
176
+ if (!allowed.includes(column)) {
177
+ return { error: 'invalid-column', status: 400 }
178
+ }
179
+ }
174
180
  const task = store.moveTask(id, { column, beforeId, afterId })
175
181
  if (task.column !== before.column) {
176
182
  store.addTransition({
package/lib/store.js CHANGED
@@ -541,6 +541,15 @@ export function openStore({ dir }) {
541
541
  return this.updateTask(id, { comments })
542
542
  },
543
543
 
544
+ addTaskComment(id, comment) {
545
+ if (!comment || typeof comment !== 'object') return undefined
546
+ return this.addComment(id, {
547
+ author: comment.author,
548
+ role: comment.role,
549
+ text: comment.text || comment.body || '',
550
+ })
551
+ },
552
+
544
553
  updateChecklist(id, items) {
545
554
  const task = rowToTask(q.taskById.get(id))
546
555
  if (!task) return undefined
@@ -1,3 +1,7 @@
1
+ function escapeRegex(str) {
2
+ return String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
3
+ }
4
+
1
5
  // Card auto-movement: derive column from observations and source priority.
2
6
  // Pure module without network, storage, or harness dependencies.
3
7
  // Observations arrive as data, verdicts return as values.
@@ -85,19 +89,19 @@ export function branchOfTask(task, branches) {
85
89
 
86
90
  // Agent creates branch matching workflow conventions with issue number.
87
91
  // Look up by issue number rather than fixed string template.
88
- if (typeof task?.issueNumber !== 'number') return undefined
89
- const marker = String(task.issueNumber)
92
+ if (!Number.isSafeInteger(task?.issueNumber) || task.issueNumber <= 0) return undefined
93
+ const marker = escapeRegex(task.issueNumber)
90
94
  return names.find((n) => new RegExp(`(^|[^0-9])${marker}([^0-9]|$)`).test(n))
91
95
  }
92
96
 
93
97
  /** Pull requests related to task: matched by branch or issue reference. */
94
98
  export function pullsOfTask(task, pulls, branch) {
95
- const number = typeof task?.issueNumber === 'number' ? task.issueNumber : undefined
99
+ const number = Number.isSafeInteger(task?.issueNumber) && task.issueNumber > 0 ? task.issueNumber : undefined
96
100
  return (pulls ?? []).filter((p) => {
97
101
  if (branch !== undefined && p?.head?.ref === branch) return true
98
102
  if (number === undefined) return false
99
103
  const haystack = `${p?.title ?? ''} ${p?.body ?? ''}`
100
- return new RegExp(`#${number}([^0-9]|$)`).test(haystack)
104
+ return new RegExp(`#${escapeRegex(number)}([^0-9]|$)`).test(haystack)
101
105
  })
102
106
  }
103
107
 
@@ -120,18 +120,20 @@ export function resolvePreset({ role, availablePresets = [], defaultPreset = und
120
120
  * @returns {string}
121
121
  */
122
122
  export function buildWorkflowScript({ task, role }) {
123
- const taskId = String(task?.id || 'unknown').replace(/["\\]/g, '')
124
- const safeRole = String(role || 'backend').replace(/["\\]/g, '')
125
- const issueNum = task?.issueNumber ? `#${task.issueNumber}` : taskId
123
+ const rawRole = String(role || 'backend')
124
+ const rawTaskId = String(task?.id || 'unknown')
125
+ const validIssue = Number.isSafeInteger(task?.issueNumber) && task.issueNumber > 0 ? task.issueNumber : undefined
126
+ const cardLabel = validIssue !== undefined ? `#${validIssue}` : rawTaskId
127
+ const logMsg = `Starting phase ${rawRole} for card ${cardLabel}`
126
128
 
127
129
  return [
128
- `phase("${safeRole}");`,
129
- `log("Starting phase ${safeRole} for card ${issueNum}");`,
130
+ `phase(${JSON.stringify(rawRole)});`,
131
+ `log(${JSON.stringify(logMsg)});`,
130
132
  `const prompt = args.prompt;`,
131
- `const result = await agent(prompt, { label: "${safeRole}", phase: "${safeRole}" });`,
133
+ `const result = await agent(prompt, { label: ${JSON.stringify(rawRole)}, phase: ${JSON.stringify(rawRole)} });`,
132
134
  `return {`,
133
- ` role: "${safeRole}",`,
134
- ` taskId: "${taskId}",`,
135
+ ` role: ${JSON.stringify(rawRole)},`,
136
+ ` taskId: ${JSON.stringify(rawTaskId)},`,
135
137
  ` summary: typeof result === 'string' ? result : (result ? JSON.stringify(result) : '')`,
136
138
  `};`,
137
139
  ].join('\n')
@@ -469,11 +471,13 @@ export class WorkflowBridge {
469
471
  }
470
472
 
471
473
  // Add comment to card
472
- if (typeof this.store.addTaskComment === 'function' && summary) {
473
- this.store.addTaskComment(taskId, {
474
- author: 'workflow',
475
- body: `🚀 **Workflow completed (${role}):**\n\n${summary}`,
476
- })
474
+ if (summary) {
475
+ const text = `🚀 **Workflow completed (${role}):**\n\n${summary}`
476
+ if (typeof this.store?.addComment === 'function') {
477
+ this.store.addComment(taskId, { author: 'workflow', role: 'workflow', text })
478
+ } else if (typeof this.store?.addTaskComment === 'function') {
479
+ this.store.addTaskComment(taskId, { author: 'workflow', role: 'workflow', text, body: text })
480
+ }
477
481
  }
478
482
  } else {
479
483
  this.store.addTransition({
package/lib/worktree.js CHANGED
@@ -87,6 +87,22 @@ export function resolveWorktreeRoot(config, env = process.env, cwdOf = () => pro
87
87
  /**
88
88
  * Resolve canonical worktree path for task with path traversal protection.
89
89
  */
90
+ export function isSafeBranchName(name) {
91
+ if (typeof name !== 'string') return false
92
+ const trimmed = name.trim()
93
+ if (trimmed === '' || trimmed.startsWith('-') || trimmed.startsWith('/') || trimmed.endsWith('/')) return false
94
+ if (trimmed.includes('..') || trimmed.includes('~') || trimmed.includes('^') || trimmed.includes(':') || trimmed.includes('?') || trimmed.includes('*') || trimmed.includes('[') || trimmed.includes('\\') || trimmed.includes('@{') || trimmed.endsWith('.lock')) return false
95
+ return /^[A-Za-z0-9_./-]+$/.test(trimmed)
96
+ }
97
+
98
+ export function isPathFenced(targetPath, rootPath) {
99
+ if (!targetPath || !rootPath) return false
100
+ const resolvedTarget = resolve(targetPath)
101
+ const resolvedRoot = resolve(rootPath)
102
+ const fence = resolvedRoot.endsWith(sep) || resolvedRoot.endsWith('/') ? resolvedRoot : resolvedRoot + sep
103
+ return resolvedTarget === resolvedRoot || resolvedTarget.startsWith(fence)
104
+ }
105
+
90
106
  export function resolveTaskWorktreePath({ task, config, env, cwdOf }) {
91
107
  const root = resolveWorktreeRoot(config, env, cwdOf)
92
108
  const repoKey = safeRepoKey(task)
@@ -262,11 +278,16 @@ export async function removeTaskWorktree({
262
278
  force = false,
263
279
  deleteBranch = true,
264
280
  gitRunner = defaultGitRunner,
281
+ rootPath,
265
282
  }) {
266
283
  if (!worktreePath || !existsSync(worktreePath)) {
267
284
  return { removed: false, reason: 'not-found' }
268
285
  }
269
286
 
287
+ if (rootPath && !isPathFenced(worktreePath, rootPath)) {
288
+ return { removed: false, error: 'unfenced-worktree-path' }
289
+ }
290
+
270
291
  const dirtyCheck = await checkWorktreeDirty({ worktreePath, gitRunner })
271
292
  if (dirtyCheck.dirty && !force) {
272
293
  return {
@@ -333,11 +354,26 @@ export async function cleanupTaskWorktree({
333
354
  force = false,
334
355
  deleteBranch = true,
335
356
  gitRunner = defaultGitRunner,
357
+ config,
358
+ env,
359
+ cwdOf,
336
360
  }) {
337
361
  const current = typeof task === 'object' && task !== null ? task : store?.getTask?.(task)
338
362
  if (!current) return { error: 'task-not-found', status: 404 }
339
363
  if (!current.worktree) return { noop: true, removed: false }
340
364
 
365
+ const root = resolveWorktreeRoot(config, env, cwdOf)
366
+ const candidateRoots = [
367
+ root,
368
+ repoDir,
369
+ typeof cwdOf === 'function' ? cwdOf() : process.cwd(),
370
+ process.cwd(),
371
+ ].filter(Boolean)
372
+ const isFenced = candidateRoots.some((c) => isPathFenced(current.worktree, c))
373
+ if (!isFenced) {
374
+ return { error: 'unfenced-worktree-path', status: 403 }
375
+ }
376
+
341
377
  const targetRepoDir = repoDir || current.worktree
342
378
  const result = await removeTaskWorktree({
343
379
  repoDir: targetRepoDir,
@@ -479,6 +515,10 @@ export async function mergeTaskWorktree({
479
515
  return { success: false, merged: false, error: 'no-worktree-or-branch' }
480
516
  }
481
517
 
518
+ if (targetBranch && !isSafeBranchName(targetBranch)) {
519
+ return { success: false, merged: false, error: 'invalid-target-branch' }
520
+ }
521
+
482
522
  // 1. Verify worktree cleanliness
483
523
  const dirtyCheck = await checkWorktreeDirty({ worktreePath: task.worktree, gitRunner })
484
524
  if (dirtyCheck.dirty) {
@@ -499,15 +539,18 @@ export async function mergeTaskWorktree({
499
539
 
500
540
  // 3. Resolve target merge branch
501
541
  let target = targetBranch
542
+ if (target && !isSafeBranchName(target)) {
543
+ return { success: false, merged: false, error: 'invalid-target-branch' }
544
+ }
502
545
  if (!target) {
503
546
  target = await determineBaseRef(mainRepoDir, gitRunner)
504
547
  }
505
- if (!target) target = 'main'
548
+ if (!target || !isSafeBranchName(target)) target = 'main'
506
549
 
507
550
  // 4. Switch or verify target branch in main repository
508
551
  const currentBranch = await gitRunner(['branch', '--show-current'], mainRepoDir)
509
552
  if (currentBranch.stdout.trim() !== target) {
510
- const checkoutRes = await gitRunner(['checkout', target], mainRepoDir)
553
+ const checkoutRes = await gitRunner(['checkout', '--', target], mainRepoDir)
511
554
  if (checkoutRes.exitCode !== 0) {
512
555
  return { success: false, merged: false, error: 'checkout-target-failed', detail: checkoutRes.stderr }
513
556
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-kanban",
3
- "version": "0.2.20",
3
+ "version": "0.2.21",
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",