@goodandready/dsh-kanban 0.2.21 → 0.2.22

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.
@@ -62,15 +62,22 @@ export function parseProgressDump(text) {
62
62
  const line = rawLine.trim()
63
63
  const lower = line.toLowerCase()
64
64
 
65
- if (lower.startsWith('goal:') || lower.startsWith('\u0446\u0435\u043b\u044c:')) {
65
+ if (lower.startsWith('goal:') || lower.startsWith('цель:')) {
66
66
  currentKey = 'goal'
67
67
  const val = line.slice(line.indexOf(':') + 1).trim()
68
68
  if (val) sections.goal.push(val)
69
- } else if (lower.startsWith('progress:') || lower.startsWith('\u043f\u0440\u043e\u0433\u0440\u0435\u0441\u0441:')) {
69
+ } else if (lower.startsWith('progress:') || lower.startsWith('done:') || lower.startsWith('прогресс:')) {
70
70
  currentKey = 'progress'
71
71
  const val = line.slice(line.indexOf(':') + 1).trim()
72
72
  if (val) sections.progress.push(val)
73
- } else if (lower.startsWith('next:') || lower.startsWith('next steps:') || lower.startsWith('\u0441\u043b\u0435\u0434\u0443\u044e\u0449\u0438\u0435 \u0448\u0430\u0433\u0438:') || lower.startsWith('\u0448\u0430\u0433\u0438:')) {
73
+ } else if (
74
+ lower.startsWith('next:') ||
75
+ lower.startsWith('next steps:') ||
76
+ lower.startsWith('next step:') ||
77
+ lower.startsWith('следующие шаги:') ||
78
+ lower.startsWith('шаги:') ||
79
+ lower.startsWith('дальше:')
80
+ ) {
74
81
  currentKey = 'next'
75
82
  const val = line.slice(line.indexOf(':') + 1).trim()
76
83
  if (val) sections.next.push(val)
@@ -98,16 +105,14 @@ export function parseProgressDump(text) {
98
105
  */
99
106
  export function formatProgressDump(dump) {
100
107
  if (!dump) return ''
108
+ const g = dump.goal || ''
109
+ const p = dump.progress || ''
110
+ const n = dump.next || ''
101
111
  return [
102
112
  BEGIN_MARKER,
103
- 'Goal:',
104
- dump.goal || '—',
105
- '',
106
- 'Progress:',
107
- dump.progress || '—',
108
- '',
109
- 'Next steps:',
110
- dump.next || '—',
113
+ `Goal: ${g}`,
114
+ `Progress: ${p}`,
115
+ `Next steps: ${n}`,
111
116
  END_MARKER,
112
117
  ].join('\n')
113
118
  }
package/lib/routes.js CHANGED
@@ -235,15 +235,30 @@ export function updateTask({ store, id, input }) {
235
235
  patch.permission = input.permission
236
236
  if (input.permission !== current.permission) {
237
237
  patch.permissionConfirmedAt = 0
238
+ patch.permissionConfirmedTier = ''
238
239
  }
239
240
  }
241
+ if (typeof input?.permissionConfirmedTier === 'string') {
242
+ patch.permissionConfirmedTier = input.permissionConfirmedTier
243
+ }
244
+ if (typeof input?.reasoningEffort === 'string') {
245
+ const val = input.reasoningEffort.trim().toLowerCase()
246
+ if (['', 'low', 'medium', 'high', 'default', 'none', 'minimal', 'thorough'].includes(val)) {
247
+ patch.reasoningEffort = val
248
+ }
249
+ }
250
+ if (typeof input?.forge === 'string') {
251
+ const val = input.forge.trim().toLowerCase()
252
+ if (val === 'github' || val === 'gitea') patch.forge = val
253
+ }
240
254
  return { task: store.updateTask(id, patch) }
241
255
  }
242
256
 
243
- export function confirmPermission({ store, id }) {
257
+ export function confirmPermission({ store, id, tier }) {
244
258
  const task = store.getTask(id)
245
259
  if (task === undefined) return { error: 'task-not-found', status: 404 }
246
- return { task: store.updateTask(id, { permissionConfirmedAt: Date.now() }) }
260
+ const effectiveTier = typeof tier === 'string' && tier ? tier : (task.permission || 'default')
261
+ return { task: store.updateTask(id, { permissionConfirmedAt: Date.now(), permissionConfirmedTier: effectiveTier }) }
247
262
  }
248
263
 
249
264
  /** Known priority values. Empty string indicates unprioritized. */
@@ -405,27 +420,22 @@ export function importBoard({ store, input }) {
405
420
  for (const task of input.tasks) {
406
421
  if (typeof task?.id !== 'string' || task.id === '') continue
407
422
  if (store.getTask(task.id) !== undefined) { skipped += 1; continue }
408
- store.createTask({
409
- id: task.id,
410
- board: task.board ?? 'main',
411
- column: task.column ?? 'backlog',
412
- title: task.title ?? '',
413
- body: task.body ?? '',
414
- owner: task.owner, repo: task.repo,
415
- issueNumber: task.issueNumber, issueUrl: task.issueUrl,
416
- labels: Array.isArray(task.labels) ? task.labels : [],
417
- branch: task.branch, worktree: task.worktree,
418
- sessionId: task.sessionId, model: task.model, provider: task.provider,
419
- waiting: task.waiting === true,
420
- plan: task.plan ?? '',
421
- labelColors: task.labelColors ?? {},
422
- author: task.author ?? '',
423
- priority: task.priority ?? '',
424
- dueAt: typeof task.dueAt === 'number' ? task.dueAt : 0,
425
- createdAt: task.createdAt,
426
- })
423
+ if (typeof store.restoreTask === 'function') {
424
+ store.restoreTask(task)
425
+ } else {
426
+ store.createTask(task)
427
+ }
427
428
  imported += 1
428
429
  }
430
+
431
+ for (const trans of Array.isArray(input.transitions) ? input.transitions : []) {
432
+ if (typeof trans?.taskId !== 'string' || trans.taskId === '') continue
433
+ if (typeof store.restoreTransition === 'function') {
434
+ store.restoreTransition(trans)
435
+ } else if (typeof store.addTransition === 'function') {
436
+ store.addTransition(trans)
437
+ }
438
+ }
429
439
  }
430
440
 
431
441
  if (typeof store.withTransaction === 'function') {
@@ -464,7 +474,12 @@ export function exportTasksCsv({ store, board = 'main' }) {
464
474
 
465
475
  function escapeCsv(val) {
466
476
  if (val === null || val === undefined) return ''
467
- const str = String(val)
477
+ let str = String(val)
478
+ // OWASP CSV Injection mitigation (#340)
479
+ const trimmed = str.replace(/^[\s\t\r\n]+/, '')
480
+ if (/^[=+\-@\t\r]/.test(str) || (trimmed.length > 0 && /^[=+\-@]/.test(trimmed))) {
481
+ str = `'${str}`
482
+ }
468
483
  if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
469
484
  return `"${str.replace(/"/g, '""')}"`
470
485
  }
@@ -809,10 +824,11 @@ export function handleToggleChecklistItem({ store, task, input }) {
809
824
  let updatedItem
810
825
  if (typeof item === 'string') {
811
826
  const checked = input?.checked !== undefined ? Boolean(input.checked) : true
812
- updatedItem = { text: item, done: checked }
827
+ updatedItem = { text: item, completed: checked, done: checked }
813
828
  } else {
814
- const checked = input?.checked !== undefined ? Boolean(input.checked) : !item.done
815
- updatedItem = { ...item, done: checked }
829
+ const isCurrentlyDone = item.completed !== undefined ? Boolean(item.completed) : Boolean(item.done)
830
+ const checked = input?.checked !== undefined ? Boolean(input.checked) : !isCurrentlyDone
831
+ updatedItem = { ...item, completed: checked, done: checked }
816
832
  }
817
833
  current[targetIndex] = updatedItem
818
834
  const updated = store.updateChecklist(task.id, current)
package/lib/store.js CHANGED
@@ -13,7 +13,7 @@ import { DatabaseSync } from 'node:sqlite'
13
13
  import { randomUUID } from 'node:crypto'
14
14
  import { mkdirSync, copyFileSync, readdirSync, unlinkSync, statSync, chmodSync, existsSync } from 'node:fs'
15
15
  import { join } from 'node:path'
16
- import { keyBetween } from './order.js'
16
+ import { keyBetween, generateNKeysBetween } from './order.js'
17
17
  import { DEFAULT_BOARDS, normalizeKind } from './config.js'
18
18
 
19
19
  const SCHEMA = `
@@ -103,7 +103,7 @@ const WRITABLE = [
103
103
  'labelColors', 'queuedAt', 'createdAt', 'author', 'assignee', 'milestone', 'priority', 'dueAt',
104
104
  'cron', 'nextRunAt', 'lastRunAt', 'progressDump', 'permission', 'reasoningEffort', 'permissionConfirmedAt',
105
105
  'runs', 'checklist', 'report', 'comments', 'rejectReason', 'rejectedAt', 'parentId', 'isEpic',
106
- 'workflowRunId', 'workflowStatus',
106
+ 'workflowRunId', 'workflowStatus', 'permissionConfirmedTier', 'forge',
107
107
  ]
108
108
 
109
109
  /** Valid transition sources; unexpected values are rejected to preserve log integrity. */
@@ -242,6 +242,8 @@ export function openStore({ dir }) {
242
242
  ['provider', "TEXT NOT NULL DEFAULT 'gitea'"],
243
243
  ['workflowRunId', "TEXT NOT NULL DEFAULT ''"],
244
244
  ['workflowStatus', "TEXT NOT NULL DEFAULT ''"],
245
+ ['forge', "TEXT NOT NULL DEFAULT ''"],
246
+ ['permissionConfirmedTier', "TEXT NOT NULL DEFAULT ''"],
245
247
  ]) {
246
248
  try { db.exec(`ALTER TABLE tasks ADD COLUMN ${column} ${definition}`) } catch { /* column already exists */ }
247
249
  }
@@ -258,6 +260,35 @@ export function openStore({ dir }) {
258
260
  db.exec('CREATE INDEX IF NOT EXISTS tasks_parent_id ON tasks(parentId)')
259
261
  db.exec('CREATE INDEX IF NOT EXISTS tasks_updated ON tasks(updatedAt)')
260
262
  db.exec('CREATE INDEX IF NOT EXISTS tasks_archivable ON tasks(archivedAt, col, columnAt)')
263
+ try { db.exec("ALTER TABLE dismissed ADD COLUMN forge TEXT NOT NULL DEFAULT ''") } catch { /* already exists */ }
264
+ db.exec("CREATE INDEX IF NOT EXISTS tasks_forge_issue ON tasks(forge, owner, repo, issueNumber)")
265
+ db.exec("UPDATE tasks SET forge = 'github' WHERE (forge = '' OR forge IS NULL) AND issueUrl LIKE '%github.com%'")
266
+ db.exec("UPDATE tasks SET forge = 'gitea' WHERE (forge = '' OR forge IS NULL) AND (issueUrl NOT LIKE '%github.com%' OR issueUrl IS NULL)")
267
+ db.exec("CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, val TEXT)")
268
+
269
+ // Fractional indexing migration (#345): ensure all existing tasks have compact keys
270
+ const orderMigrated = db.prepare("SELECT val FROM meta WHERE key = 'fractional_indexing_migrated'").get()
271
+ if (!orderMigrated) {
272
+ try {
273
+ db.exec('BEGIN TRANSACTION')
274
+ const groups = db.prepare('SELECT DISTINCT board, col FROM tasks').all()
275
+ const getTasksInCol = db.prepare('SELECT id, position FROM tasks WHERE board = ? AND col = ? ORDER BY position ASC, createdAt ASC')
276
+ const updatePos = db.prepare('UPDATE tasks SET position = ? WHERE id = ?')
277
+ for (const group of groups) {
278
+ const rows = getTasksInCol.all(group.board, group.col)
279
+ if (rows.length > 0) {
280
+ const newKeys = generateNKeysBetween(null, null, rows.length)
281
+ for (let i = 0; i < rows.length; i++) {
282
+ updatePos.run(newKeys[i], rows[i].id)
283
+ }
284
+ }
285
+ }
286
+ db.prepare("INSERT OR REPLACE INTO meta (key, val) VALUES ('fractional_indexing_migrated', '1')").run()
287
+ db.exec('COMMIT')
288
+ } catch (e) {
289
+ try { db.exec('ROLLBACK') } catch { /* ignore */ }
290
+ }
291
+ }
261
292
  db.exec('PRAGMA synchronous = NORMAL')
262
293
  try { db.exec('PRAGMA optimize') } catch { /* pragma optimize best-effort */ }
263
294
 
@@ -274,6 +305,35 @@ export function openStore({ dir }) {
274
305
  checklist, report, comments, rejectReason, rejectedAt, reasoningEffort, parentId, isEpic,
275
306
  workflowRunId, workflowStatus)
276
307
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`),
308
+ restoreTask: db.prepare(`INSERT INTO tasks
309
+ (id, board, col, position, title, body, owner, repo, issueNumber, issueUrl, externalRef,
310
+ labels, branch, worktree, workspaceId, sessionId, model, provider, waiting, syncedAt,
311
+ labelColors, author, assignee, milestone, priority, dueAt, columnAt, createdAt, updatedAt,
312
+ cron, nextRunAt, lastRunAt, progressDump, permission, permissionConfirmedAt, permissionConfirmedTier, runs,
313
+ checklist, report, comments, rejectReason, rejectedAt, reasoningEffort, parentId, isEpic,
314
+ workflowRunId, workflowStatus, archivedAt, queuedAt, plan, forge)
315
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
316
+ ON CONFLICT(id) DO UPDATE SET
317
+ board=excluded.board, col=excluded.col, position=excluded.position, title=excluded.title,
318
+ body=excluded.body, owner=excluded.owner, repo=excluded.repo, issueNumber=excluded.issueNumber,
319
+ issueUrl=excluded.issueUrl, externalRef=excluded.externalRef, labels=excluded.labels,
320
+ branch=excluded.branch, worktree=excluded.worktree, workspaceId=excluded.workspaceId,
321
+ sessionId=excluded.sessionId, model=excluded.model, provider=excluded.provider,
322
+ waiting=excluded.waiting, syncedAt=excluded.syncedAt, labelColors=excluded.labelColors,
323
+ author=excluded.author, assignee=excluded.assignee, milestone=excluded.milestone,
324
+ priority=excluded.priority, dueAt=excluded.dueAt, columnAt=excluded.columnAt,
325
+ createdAt=excluded.createdAt, updatedAt=excluded.updatedAt, cron=excluded.cron,
326
+ nextRunAt=excluded.nextRunAt, lastRunAt=excluded.lastRunAt, progressDump=excluded.progressDump,
327
+ permission=excluded.permission, permissionConfirmedAt=excluded.permissionConfirmedAt,
328
+ permissionConfirmedTier=excluded.permissionConfirmedTier, runs=excluded.runs,
329
+ checklist=excluded.checklist, report=excluded.report, comments=excluded.comments,
330
+ rejectReason=excluded.rejectReason, rejectedAt=excluded.rejectedAt,
331
+ reasoningEffort=excluded.reasoningEffort, parentId=excluded.parentId, isEpic=excluded.isEpic,
332
+ workflowRunId=excluded.workflowRunId, workflowStatus=excluded.workflowStatus,
333
+ archivedAt=excluded.archivedAt, queuedAt=excluded.queuedAt, plan=excluded.plan, forge=excluded.forge`),
334
+ restoreTransition: db.prepare(`INSERT INTO transitions
335
+ (taskId, fromCol, toCol, source, detail, at)
336
+ VALUES (?, ?, ?, ?, ?, ?)`),
277
337
  boardById: db.prepare('SELECT * FROM boards WHERE id = ?'),
278
338
  insertBoard: db.prepare('INSERT INTO boards (id, title, kind, position, createdAt) VALUES (?, ?, ?, ?, ?)'),
279
339
  lastBoard: db.prepare('SELECT position FROM boards ORDER BY position DESC LIMIT 1'),
@@ -286,7 +346,9 @@ export function openStore({ dir }) {
286
346
  "SELECT * FROM tasks WHERE archivedAt = 0 AND col = 'done' AND columnAt > 0 AND columnAt <= ?"),
287
347
  dismiss: db.prepare('INSERT OR REPLACE INTO dismissed (owner, repo, issueNumber, at) VALUES (?, ?, ?, ?)'),
288
348
  isDismissed: db.prepare('SELECT 1 FROM dismissed WHERE owner = ? AND repo = ? AND issueNumber = ?'),
349
+ isDismissedWithForge: db.prepare(`SELECT 1 FROM dismissed WHERE owner = ? AND repo = ? AND issueNumber = ? AND (forge = ? OR (forge = '' AND ? = 'gitea'))`),
289
350
  byIssue: db.prepare('SELECT * FROM tasks WHERE owner = ? AND repo = ? AND issueNumber = ?'),
351
+ byIssueWithForge: db.prepare(`SELECT * FROM tasks WHERE owner = ? AND repo = ? AND issueNumber = ? AND (forge = ? OR (forge = '' AND ? = 'gitea'))`),
290
352
  byExternalRef: db.prepare('SELECT * FROM tasks WHERE externalRef = ? ORDER BY createdAt ASC LIMIT 1'),
291
353
  tasksBySession: db.prepare('SELECT * FROM tasks WHERE sessionId = ? LIMIT 1'),
292
354
  lastInColumn: db.prepare('SELECT position FROM tasks WHERE board = ? AND col = ? ORDER BY position DESC LIMIT 1'),
@@ -553,10 +615,91 @@ export function openStore({ dir }) {
553
615
  updateChecklist(id, items) {
554
616
  const task = rowToTask(q.taskById.get(id))
555
617
  if (!task) return undefined
556
- const list = Array.isArray(items) ? items : []
618
+ const list = (Array.isArray(items) ? items : []).map((item) => {
619
+ if (typeof item === 'string') return { text: item, completed: false, done: false }
620
+ const c = item.completed !== undefined ? Boolean(item.completed) : Boolean(item.done)
621
+ return { ...item, completed: c, done: c }
622
+ })
557
623
  return this.updateTask(id, { checklist: list })
558
624
  },
559
625
 
626
+ restoreTask(task) {
627
+ if (!task || !task.id) return undefined
628
+ const labels = JSON.stringify(Array.isArray(task.labels) ? task.labels : [])
629
+ const colors = JSON.stringify(task.labelColors && typeof task.labelColors === 'object' ? task.labelColors : {})
630
+ const runs = JSON.stringify(Array.isArray(task.runs) ? task.runs : [])
631
+ const checklist = JSON.stringify(Array.isArray(task.checklist) ? task.checklist : [])
632
+ const report = JSON.stringify(task.report && typeof task.report === 'object' ? task.report : {})
633
+ const comments = JSON.stringify(Array.isArray(task.comments) ? task.comments : [])
634
+ const forge = task.forge || (typeof task.issueUrl === 'string' && task.issueUrl.includes('github.com') ? 'github' : 'gitea')
635
+ q.restoreTask.run(
636
+ task.id,
637
+ task.board || 'main',
638
+ task.column || task.col || 'backlog',
639
+ task.position || keyBetween(undefined, undefined),
640
+ task.title || '',
641
+ task.body || '',
642
+ task.owner ?? null,
643
+ task.repo ?? null,
644
+ task.issueNumber ?? null,
645
+ task.issueUrl ?? null,
646
+ task.externalRef ?? null,
647
+ labels,
648
+ task.branch ?? null,
649
+ task.worktree ?? null,
650
+ task.workspaceId || '',
651
+ task.sessionId ?? null,
652
+ task.model ?? null,
653
+ task.provider ?? 'gitea',
654
+ task.waiting ? 1 : 0,
655
+ task.syncedAt || 0,
656
+ colors,
657
+ task.author || '',
658
+ task.assignee || '',
659
+ task.milestone || '',
660
+ task.priority || '',
661
+ task.dueAt || 0,
662
+ task.columnAt || task.createdAt || Date.now(),
663
+ task.createdAt || Date.now(),
664
+ task.updatedAt || Date.now(),
665
+ task.cron || '',
666
+ task.nextRunAt || 0,
667
+ task.lastRunAt || 0,
668
+ task.progressDump || '',
669
+ task.permission || '',
670
+ task.permissionConfirmedAt || 0,
671
+ task.permissionConfirmedTier || '',
672
+ runs,
673
+ checklist,
674
+ report,
675
+ comments,
676
+ task.rejectReason || '',
677
+ task.rejectedAt || 0,
678
+ task.reasoningEffort || '',
679
+ task.parentId || '',
680
+ task.isEpic ? 1 : 0,
681
+ task.workflowRunId || '',
682
+ task.workflowStatus || '',
683
+ task.archivedAt || 0,
684
+ task.queuedAt || 0,
685
+ task.plan || '',
686
+ forge,
687
+ )
688
+ return rowToTask(q.taskById.get(task.id))
689
+ },
690
+
691
+ restoreTransition(trans) {
692
+ if (!trans || !trans.taskId) return undefined
693
+ q.restoreTransition.run(
694
+ trans.taskId,
695
+ trans.fromCol ?? null,
696
+ trans.toCol || '',
697
+ trans.source || 'unknown',
698
+ trans.detail || '',
699
+ trans.at || Date.now(),
700
+ )
701
+ },
702
+
560
703
  setReport(id, reportData) {
561
704
  const task = rowToTask(q.taskById.get(id))
562
705
  if (!task) return undefined
@@ -621,12 +764,18 @@ export function openStore({ dir }) {
621
764
  },
622
765
 
623
766
  /** Whether an issue was previously dismissed. */
624
- isDismissed({ owner, repo, issueNumber }) {
767
+ isDismissed({ owner, repo, issueNumber, forge }) {
768
+ if (forge) {
769
+ return q.isDismissedWithForge.get(owner, repo, issueNumber, forge, forge) !== undefined
770
+ }
625
771
  return q.isDismissed.get(owner, repo, issueNumber) !== undefined
626
772
  },
627
773
 
628
774
  /** Find task by issue to prevent duplicate import. */
629
- findTaskByIssue({ owner, repo, issueNumber }) {
775
+ findTaskByIssue({ owner, repo, issueNumber, forge }) {
776
+ if (forge) {
777
+ return rowToTask(q.byIssueWithForge.get(owner, repo, issueNumber, forge, forge))
778
+ }
630
779
  return rowToTask(q.byIssue.get(owner, repo, issueNumber))
631
780
  },
632
781
 
@@ -667,8 +816,9 @@ export function openStore({ dir }) {
667
816
  },
668
817
 
669
818
  setArchived(id, archivedAt) {
819
+ const at = typeof archivedAt === 'boolean' ? (archivedAt ? Date.now() : 0) : (Number(archivedAt) || 0)
670
820
  db.prepare('UPDATE tasks SET archivedAt = ?, updatedAt = ? WHERE id = ?')
671
- .run(archivedAt, Date.now(), id)
821
+ .run(at, Date.now(), id)
672
822
  return rowToTask(q.taskById.get(id))
673
823
  },
674
824
 
package/lib/sync.js CHANGED
@@ -39,7 +39,7 @@ export async function withBackoff(fn, { retries = 2, baseMs = 50, maxMs = 500 }
39
39
  * Failures are not silently swallowed: incomplete observations (missing PRs or branches)
40
40
  * would lead to erroneous column transitions. If reading fails, the repository is skipped.
41
41
  */
42
- async function readRepo({ gitea, owner, repo }) {
42
+ async function readRepo({ gitea, owner, repo, forge }) {
43
43
  const [pulls, branches] = await Promise.all([
44
44
  gitea.listPulls({ owner, repo, state: 'all' }),
45
45
  gitea.listBranches({ owner, repo }),
@@ -182,8 +182,9 @@ export async function syncAll({ gitea, store, logger, only, now = Date.now() })
182
182
  const tasks = store.listWatchable().filter((t) => (only ? only(t) : true))
183
183
  const byRepo = new Map()
184
184
  for (const task of tasks) {
185
- const key = `${task.owner}/${task.repo}`
186
- if (!byRepo.has(key)) byRepo.set(key, { owner: task.owner, repo: task.repo, tasks: [] })
185
+ const forge = task.forge || (typeof task.issueUrl === 'string' && task.issueUrl.includes('github.com') ? 'github' : 'gitea')
186
+ const key = `${forge}:${task.owner}/${task.repo}`
187
+ if (!byRepo.has(key)) byRepo.set(key, { forge, owner: task.owner, repo: task.repo, tasks: [] })
187
188
  byRepo.get(key).tasks.push(task)
188
189
  }
189
190
 
@@ -200,7 +201,7 @@ export async function syncAll({ gitea, store, logger, only, now = Date.now() })
200
201
  for (const group of byRepo.values()) {
201
202
  let repoState
202
203
  try {
203
- repoState = await withBackoff(() => readRepo({ gitea, owner: group.owner, repo: group.repo }), { retries: 2, baseMs: 40 })
204
+ repoState = await withBackoff(() => readRepo({ gitea, owner: group.owner, repo: group.repo, forge: group.forge }), { retries: 2, baseMs: 40 })
204
205
  } catch (error) {
205
206
  failed += group.tasks.length
206
207
  reposFailed += 1
@@ -212,7 +213,7 @@ export async function syncAll({ gitea, store, logger, only, now = Date.now() })
212
213
  for (const task of group.tasks) {
213
214
  checked += 1
214
215
  try {
215
- const issue = await gitea.getIssue({ owner: task.owner, repo: task.repo, index: task.issueNumber })
216
+ const issue = await gitea.getIssue({ owner: task.owner, repo: task.repo, index: task.issueNumber, provider: group.forge, issueUrl: task.issueUrl })
216
217
  const observation = observeTask(task, repoState, issue)
217
218
  const applied = applyObservation({
218
219
  store, task, observation, issue, remoteUpdatedAt: issue?.updated_at,
@@ -11,7 +11,14 @@
11
11
  // - post summary to linked Gitea / GitHub issue.
12
12
  // 5. Cancel active workflow on card request.
13
13
 
14
- import { buildStartMessage, obtainAgent } from './launcher.js'
14
+ import {
15
+ buildStartMessage,
16
+ obtainAgent,
17
+ verifyPermissionGate,
18
+ countActiveSessions,
19
+ reserveSessionSlot,
20
+ releaseSessionSlot,
21
+ } from './launcher.js'
15
22
  import { applyMove } from './routes.js'
16
23
  import { defaultGitRunner } from './worktree.js'
17
24
 
@@ -169,11 +176,11 @@ export function buildWorkflowMeta({ task, role }) {
169
176
  */
170
177
  export function resolveTargetColumn(currentCol) {
171
178
  const col = String(currentCol || '').toLowerCase()
172
- if (col === 'backlog' || col === 'ready' || col === 'progress') {
179
+ if (col === 'backlog' || col === 'ready' || col === 'progress' || col === 'in-progress') {
173
180
  return 'review'
174
181
  }
175
182
  if (col === 'review') {
176
- return 'done'
183
+ return 'review'
177
184
  }
178
185
  return col || 'review'
179
186
  }
@@ -268,21 +275,40 @@ export class WorkflowBridge {
268
275
  })
269
276
 
270
277
  const config = this.getConfig()
271
- const taken = await obtainAgent({
272
- agents: this.agents,
273
- task,
274
- config,
275
- provider,
276
- model,
277
- mintSessionId,
278
- cwdOf,
279
- logger: this.logger,
280
- agentPreset: preset,
281
- permission,
282
- permissions,
283
- gitRunner,
284
- workspaceRegistry,
285
- })
278
+ verifyPermissionGate({ task, permission, config })
279
+
280
+ const maxConcurrent = typeof config?.maxConcurrentSessions === 'number' ? config.maxConcurrentSessions : 3
281
+ if (maxConcurrent > 0 && this.agents && this.store) {
282
+ const activeCount = countActiveSessions({ store: this.store, agents: this.agents })
283
+ if (activeCount >= maxConcurrent) {
284
+ const err = new Error('concurrent-limit-reached')
285
+ err.key = 'concurrent-limit-reached'
286
+ err.status = 429
287
+ throw err
288
+ }
289
+ }
290
+
291
+ reserveSessionSlot(task.id)
292
+ let taken
293
+ try {
294
+ taken = await obtainAgent({
295
+ agents: this.agents,
296
+ task,
297
+ config,
298
+ provider,
299
+ model,
300
+ mintSessionId,
301
+ cwdOf,
302
+ logger: this.logger,
303
+ agentPreset: preset,
304
+ permission,
305
+ permissions,
306
+ gitRunner,
307
+ workspaceRegistry,
308
+ })
309
+ } finally {
310
+ releaseSessionSlot(task.id)
311
+ }
286
312
 
287
313
  const promptText = typeof text === 'string' && text.trim() !== ''
288
314
  ? text.trim()
package/lib/worktree.js CHANGED
@@ -30,10 +30,11 @@ export async function defaultGitRunner(args, cwd) {
30
30
  maxBuffer: 10 * 1024 * 1024,
31
31
  env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
32
32
  })
33
- return { stdout: stdout.trim(), stderr: stderr.trim(), code: 0 }
33
+ return { stdout: stdout.trim(), stderr: stderr.trim(), code: 0, exitCode: 0 }
34
34
  } catch (err) {
35
35
  const error = new Error(`git ${args.join(' ')} failed: ${err.stderr || err.message}`)
36
36
  error.code = err.code || 1
37
+ error.exitCode = err.code || 1
37
38
  error.stdout = err.stdout ? String(err.stdout).trim() : ''
38
39
  error.stderr = err.stderr ? String(err.stderr).trim() : ''
39
40
  throw error
@@ -264,7 +265,7 @@ export async function checkWorktreeDirty({ worktreePath, gitRunner = defaultGitR
264
265
  .filter((line) => !isSubmoduleDrift(worktreePath, line))
265
266
  return { dirty: files.length > 0, files }
266
267
  } catch (error) {
267
- return { dirty: false, files: [], error: String(error?.message ?? error) }
268
+ return { dirty: true, files: [], error: String(error?.message ?? error) }
268
269
  }
269
270
  }
270
271
 
@@ -289,6 +290,13 @@ export async function removeTaskWorktree({
289
290
  }
290
291
 
291
292
  const dirtyCheck = await checkWorktreeDirty({ worktreePath, gitRunner })
293
+ if (dirtyCheck.error) {
294
+ return {
295
+ removed: false,
296
+ error: 'worktree-status-error',
297
+ detail: dirtyCheck.error,
298
+ }
299
+ }
292
300
  if (dirtyCheck.dirty && !force) {
293
301
  return {
294
302
  removed: false,
@@ -431,21 +439,21 @@ export async function getTaskDiff({ worktreePath, baseRef, gitRunner = defaultGi
431
439
  let diffOut = ''
432
440
  if (base) {
433
441
  const res = await gitRunner(['diff', `${base}...HEAD`], worktreePath)
434
- if (res.exitCode === 0) {
442
+ if ((res?.exitCode ?? res?.code ?? 0) === 0) {
435
443
  diffOut = res.stdout
436
444
  }
437
445
  }
438
446
 
439
447
  // Include uncommitted changes (unstaged + staged)
440
448
  const unstaged = await gitRunner(['diff', 'HEAD'], worktreePath)
441
- if (unstaged.exitCode === 0 && unstaged.stdout.trim() !== '') {
449
+ if ((unstaged?.exitCode ?? unstaged?.code ?? 0) === 0 && unstaged.stdout.trim() !== '') {
442
450
  diffOut = diffOut ? `${diffOut}\n\n# Uncommitted Changes:\n${unstaged.stdout}` : unstaged.stdout
443
451
  }
444
452
 
445
453
  // Fallback to plain git diff if base...HEAD produced no output
446
454
  if (!diffOut.trim()) {
447
455
  const fallback = await gitRunner(['diff'], worktreePath)
448
- if (fallback.exitCode === 0) diffOut = fallback.stdout
456
+ if ((fallback?.exitCode ?? fallback?.code ?? 0) === 0) diffOut = fallback.stdout
449
457
  }
450
458
 
451
459
  // Count affected files
@@ -481,10 +489,10 @@ export async function getTaskCommits({ worktreePath, baseRef, gitRunner = defaul
481
489
 
482
490
  const range = base ? `${base}..HEAD` : '-n 20'
483
491
  const logRes = await gitRunner(['log', range, '--pretty=format:%h%x09%an%x09%ad%x09%s', '--date=short'], worktreePath)
484
- if (logRes.exitCode !== 0 || !logRes.stdout.trim()) {
492
+ if ((logRes?.exitCode ?? logRes?.code ?? 0) !== 0 || !logRes.stdout.trim()) {
485
493
  // Fallback to last 10 commits
486
494
  const fallback = await gitRunner(['log', '-n', '10', '--pretty=format:%h%x09%an%x09%ad%x09%s', '--date=short'], worktreePath)
487
- if (fallback.exitCode !== 0 || !fallback.stdout.trim()) return []
495
+ if ((fallback?.exitCode ?? fallback?.code ?? 0) !== 0 || !fallback.stdout.trim()) return []
488
496
  return fallback.stdout.trim().split('\n').map(parseCommitLine).filter(Boolean)
489
497
  }
490
498
 
@@ -521,6 +529,9 @@ export async function mergeTaskWorktree({
521
529
 
522
530
  // 1. Verify worktree cleanliness
523
531
  const dirtyCheck = await checkWorktreeDirty({ worktreePath: task.worktree, gitRunner })
532
+ if (dirtyCheck.error) {
533
+ return { success: false, merged: false, error: 'worktree-status-error', detail: dirtyCheck.error }
534
+ }
524
535
  if (dirtyCheck.dirty) {
525
536
  return { success: false, merged: false, error: 'worktree-dirty', files: dirtyCheck.files }
526
537
  }
@@ -528,7 +539,7 @@ export async function mergeTaskWorktree({
528
539
  // 2. Locate main repository root
529
540
  let mainRepoDir = ''
530
541
  const revParse = await gitRunner(['rev-parse', '--git-common-dir'], task.worktree)
531
- if (revParse.exitCode === 0 && revParse.stdout.trim()) {
542
+ if ((revParse?.exitCode ?? revParse?.code ?? 0) === 0 && revParse.stdout.trim()) {
532
543
  const commonDir = revParse.stdout.trim()
533
544
  mainRepoDir = commonDir.endsWith('/.git') ? commonDir.slice(0, -5) : commonDir
534
545
  }
@@ -551,7 +562,7 @@ export async function mergeTaskWorktree({
551
562
  const currentBranch = await gitRunner(['branch', '--show-current'], mainRepoDir)
552
563
  if (currentBranch.stdout.trim() !== target) {
553
564
  const checkoutRes = await gitRunner(['checkout', '--', target], mainRepoDir)
554
- if (checkoutRes.exitCode !== 0) {
565
+ if ((checkoutRes?.exitCode ?? checkoutRes?.code ?? 0) !== 0) {
555
566
  return { success: false, merged: false, error: 'checkout-target-failed', detail: checkoutRes.stderr }
556
567
  }
557
568
  }
@@ -559,7 +570,7 @@ export async function mergeTaskWorktree({
559
570
  // 5. Execute git merge --no-ff
560
571
  const mergeMsg = `Merge branch '${task.branch}' for task ${task.id}`
561
572
  const mergeRes = await gitRunner(['merge', '--no-ff', task.branch, '-m', mergeMsg], mainRepoDir)
562
- if (mergeRes.exitCode !== 0) {
573
+ if ((mergeRes?.exitCode ?? mergeRes?.code ?? 0) !== 0) {
563
574
  // Roll back merge on conflict
564
575
  await gitRunner(['merge', '--abort'], mainRepoDir)
565
576
  return { success: false, merged: false, error: 'merge-conflict', detail: mergeRes.stderr || mergeRes.stdout }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-kanban",
3
- "version": "0.2.21",
3
+ "version": "0.2.22",
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",