@goodandready/dsh-kanban 0.2.20 → 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.
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'),
@@ -541,13 +603,103 @@ export function openStore({ dir }) {
541
603
  return this.updateTask(id, { comments })
542
604
  },
543
605
 
606
+ addTaskComment(id, comment) {
607
+ if (!comment || typeof comment !== 'object') return undefined
608
+ return this.addComment(id, {
609
+ author: comment.author,
610
+ role: comment.role,
611
+ text: comment.text || comment.body || '',
612
+ })
613
+ },
614
+
544
615
  updateChecklist(id, items) {
545
616
  const task = rowToTask(q.taskById.get(id))
546
617
  if (!task) return undefined
547
- 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
+ })
548
623
  return this.updateTask(id, { checklist: list })
549
624
  },
550
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
+
551
703
  setReport(id, reportData) {
552
704
  const task = rowToTask(q.taskById.get(id))
553
705
  if (!task) return undefined
@@ -612,12 +764,18 @@ export function openStore({ dir }) {
612
764
  },
613
765
 
614
766
  /** Whether an issue was previously dismissed. */
615
- 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
+ }
616
771
  return q.isDismissed.get(owner, repo, issueNumber) !== undefined
617
772
  },
618
773
 
619
774
  /** Find task by issue to prevent duplicate import. */
620
- 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
+ }
621
779
  return rowToTask(q.byIssue.get(owner, repo, issueNumber))
622
780
  },
623
781
 
@@ -658,8 +816,9 @@ export function openStore({ dir }) {
658
816
  },
659
817
 
660
818
  setArchived(id, archivedAt) {
819
+ const at = typeof archivedAt === 'boolean' ? (archivedAt ? Date.now() : 0) : (Number(archivedAt) || 0)
661
820
  db.prepare('UPDATE tasks SET archivedAt = ?, updatedAt = ? WHERE id = ?')
662
- .run(archivedAt, Date.now(), id)
821
+ .run(at, Date.now(), id)
663
822
  return rowToTask(q.taskById.get(id))
664
823
  },
665
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,
@@ -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
 
@@ -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
 
@@ -120,18 +127,20 @@ export function resolvePreset({ role, availablePresets = [], defaultPreset = und
120
127
  * @returns {string}
121
128
  */
122
129
  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
130
+ const rawRole = String(role || 'backend')
131
+ const rawTaskId = String(task?.id || 'unknown')
132
+ const validIssue = Number.isSafeInteger(task?.issueNumber) && task.issueNumber > 0 ? task.issueNumber : undefined
133
+ const cardLabel = validIssue !== undefined ? `#${validIssue}` : rawTaskId
134
+ const logMsg = `Starting phase ${rawRole} for card ${cardLabel}`
126
135
 
127
136
  return [
128
- `phase("${safeRole}");`,
129
- `log("Starting phase ${safeRole} for card ${issueNum}");`,
137
+ `phase(${JSON.stringify(rawRole)});`,
138
+ `log(${JSON.stringify(logMsg)});`,
130
139
  `const prompt = args.prompt;`,
131
- `const result = await agent(prompt, { label: "${safeRole}", phase: "${safeRole}" });`,
140
+ `const result = await agent(prompt, { label: ${JSON.stringify(rawRole)}, phase: ${JSON.stringify(rawRole)} });`,
132
141
  `return {`,
133
- ` role: "${safeRole}",`,
134
- ` taskId: "${taskId}",`,
142
+ ` role: ${JSON.stringify(rawRole)},`,
143
+ ` taskId: ${JSON.stringify(rawTaskId)},`,
135
144
  ` summary: typeof result === 'string' ? result : (result ? JSON.stringify(result) : '')`,
136
145
  `};`,
137
146
  ].join('\n')
@@ -167,11 +176,11 @@ export function buildWorkflowMeta({ task, role }) {
167
176
  */
168
177
  export function resolveTargetColumn(currentCol) {
169
178
  const col = String(currentCol || '').toLowerCase()
170
- if (col === 'backlog' || col === 'ready' || col === 'progress') {
179
+ if (col === 'backlog' || col === 'ready' || col === 'progress' || col === 'in-progress') {
171
180
  return 'review'
172
181
  }
173
182
  if (col === 'review') {
174
- return 'done'
183
+ return 'review'
175
184
  }
176
185
  return col || 'review'
177
186
  }
@@ -266,21 +275,40 @@ export class WorkflowBridge {
266
275
  })
267
276
 
268
277
  const config = this.getConfig()
269
- const taken = await obtainAgent({
270
- agents: this.agents,
271
- task,
272
- config,
273
- provider,
274
- model,
275
- mintSessionId,
276
- cwdOf,
277
- logger: this.logger,
278
- agentPreset: preset,
279
- permission,
280
- permissions,
281
- gitRunner,
282
- workspaceRegistry,
283
- })
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
+ }
284
312
 
285
313
  const promptText = typeof text === 'string' && text.trim() !== ''
286
314
  ? text.trim()
@@ -469,11 +497,13 @@ export class WorkflowBridge {
469
497
  }
470
498
 
471
499
  // 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
- })
500
+ if (summary) {
501
+ const text = `🚀 **Workflow completed (${role}):**\n\n${summary}`
502
+ if (typeof this.store?.addComment === 'function') {
503
+ this.store.addComment(taskId, { author: 'workflow', role: 'workflow', text })
504
+ } else if (typeof this.store?.addTaskComment === 'function') {
505
+ this.store.addTaskComment(taskId, { author: 'workflow', role: 'workflow', text, body: text })
506
+ }
477
507
  }
478
508
  } else {
479
509
  this.store.addTransition({
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
@@ -87,6 +88,22 @@ export function resolveWorktreeRoot(config, env = process.env, cwdOf = () => pro
87
88
  /**
88
89
  * Resolve canonical worktree path for task with path traversal protection.
89
90
  */
91
+ export function isSafeBranchName(name) {
92
+ if (typeof name !== 'string') return false
93
+ const trimmed = name.trim()
94
+ if (trimmed === '' || trimmed.startsWith('-') || trimmed.startsWith('/') || trimmed.endsWith('/')) return false
95
+ if (trimmed.includes('..') || trimmed.includes('~') || trimmed.includes('^') || trimmed.includes(':') || trimmed.includes('?') || trimmed.includes('*') || trimmed.includes('[') || trimmed.includes('\\') || trimmed.includes('@{') || trimmed.endsWith('.lock')) return false
96
+ return /^[A-Za-z0-9_./-]+$/.test(trimmed)
97
+ }
98
+
99
+ export function isPathFenced(targetPath, rootPath) {
100
+ if (!targetPath || !rootPath) return false
101
+ const resolvedTarget = resolve(targetPath)
102
+ const resolvedRoot = resolve(rootPath)
103
+ const fence = resolvedRoot.endsWith(sep) || resolvedRoot.endsWith('/') ? resolvedRoot : resolvedRoot + sep
104
+ return resolvedTarget === resolvedRoot || resolvedTarget.startsWith(fence)
105
+ }
106
+
90
107
  export function resolveTaskWorktreePath({ task, config, env, cwdOf }) {
91
108
  const root = resolveWorktreeRoot(config, env, cwdOf)
92
109
  const repoKey = safeRepoKey(task)
@@ -248,7 +265,7 @@ export async function checkWorktreeDirty({ worktreePath, gitRunner = defaultGitR
248
265
  .filter((line) => !isSubmoduleDrift(worktreePath, line))
249
266
  return { dirty: files.length > 0, files }
250
267
  } catch (error) {
251
- return { dirty: false, files: [], error: String(error?.message ?? error) }
268
+ return { dirty: true, files: [], error: String(error?.message ?? error) }
252
269
  }
253
270
  }
254
271
 
@@ -262,12 +279,24 @@ export async function removeTaskWorktree({
262
279
  force = false,
263
280
  deleteBranch = true,
264
281
  gitRunner = defaultGitRunner,
282
+ rootPath,
265
283
  }) {
266
284
  if (!worktreePath || !existsSync(worktreePath)) {
267
285
  return { removed: false, reason: 'not-found' }
268
286
  }
269
287
 
288
+ if (rootPath && !isPathFenced(worktreePath, rootPath)) {
289
+ return { removed: false, error: 'unfenced-worktree-path' }
290
+ }
291
+
270
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
+ }
271
300
  if (dirtyCheck.dirty && !force) {
272
301
  return {
273
302
  removed: false,
@@ -333,11 +362,26 @@ export async function cleanupTaskWorktree({
333
362
  force = false,
334
363
  deleteBranch = true,
335
364
  gitRunner = defaultGitRunner,
365
+ config,
366
+ env,
367
+ cwdOf,
336
368
  }) {
337
369
  const current = typeof task === 'object' && task !== null ? task : store?.getTask?.(task)
338
370
  if (!current) return { error: 'task-not-found', status: 404 }
339
371
  if (!current.worktree) return { noop: true, removed: false }
340
372
 
373
+ const root = resolveWorktreeRoot(config, env, cwdOf)
374
+ const candidateRoots = [
375
+ root,
376
+ repoDir,
377
+ typeof cwdOf === 'function' ? cwdOf() : process.cwd(),
378
+ process.cwd(),
379
+ ].filter(Boolean)
380
+ const isFenced = candidateRoots.some((c) => isPathFenced(current.worktree, c))
381
+ if (!isFenced) {
382
+ return { error: 'unfenced-worktree-path', status: 403 }
383
+ }
384
+
341
385
  const targetRepoDir = repoDir || current.worktree
342
386
  const result = await removeTaskWorktree({
343
387
  repoDir: targetRepoDir,
@@ -395,21 +439,21 @@ export async function getTaskDiff({ worktreePath, baseRef, gitRunner = defaultGi
395
439
  let diffOut = ''
396
440
  if (base) {
397
441
  const res = await gitRunner(['diff', `${base}...HEAD`], worktreePath)
398
- if (res.exitCode === 0) {
442
+ if ((res?.exitCode ?? res?.code ?? 0) === 0) {
399
443
  diffOut = res.stdout
400
444
  }
401
445
  }
402
446
 
403
447
  // Include uncommitted changes (unstaged + staged)
404
448
  const unstaged = await gitRunner(['diff', 'HEAD'], worktreePath)
405
- if (unstaged.exitCode === 0 && unstaged.stdout.trim() !== '') {
449
+ if ((unstaged?.exitCode ?? unstaged?.code ?? 0) === 0 && unstaged.stdout.trim() !== '') {
406
450
  diffOut = diffOut ? `${diffOut}\n\n# Uncommitted Changes:\n${unstaged.stdout}` : unstaged.stdout
407
451
  }
408
452
 
409
453
  // Fallback to plain git diff if base...HEAD produced no output
410
454
  if (!diffOut.trim()) {
411
455
  const fallback = await gitRunner(['diff'], worktreePath)
412
- if (fallback.exitCode === 0) diffOut = fallback.stdout
456
+ if ((fallback?.exitCode ?? fallback?.code ?? 0) === 0) diffOut = fallback.stdout
413
457
  }
414
458
 
415
459
  // Count affected files
@@ -445,10 +489,10 @@ export async function getTaskCommits({ worktreePath, baseRef, gitRunner = defaul
445
489
 
446
490
  const range = base ? `${base}..HEAD` : '-n 20'
447
491
  const logRes = await gitRunner(['log', range, '--pretty=format:%h%x09%an%x09%ad%x09%s', '--date=short'], worktreePath)
448
- if (logRes.exitCode !== 0 || !logRes.stdout.trim()) {
492
+ if ((logRes?.exitCode ?? logRes?.code ?? 0) !== 0 || !logRes.stdout.trim()) {
449
493
  // Fallback to last 10 commits
450
494
  const fallback = await gitRunner(['log', '-n', '10', '--pretty=format:%h%x09%an%x09%ad%x09%s', '--date=short'], worktreePath)
451
- if (fallback.exitCode !== 0 || !fallback.stdout.trim()) return []
495
+ if ((fallback?.exitCode ?? fallback?.code ?? 0) !== 0 || !fallback.stdout.trim()) return []
452
496
  return fallback.stdout.trim().split('\n').map(parseCommitLine).filter(Boolean)
453
497
  }
454
498
 
@@ -479,8 +523,15 @@ export async function mergeTaskWorktree({
479
523
  return { success: false, merged: false, error: 'no-worktree-or-branch' }
480
524
  }
481
525
 
526
+ if (targetBranch && !isSafeBranchName(targetBranch)) {
527
+ return { success: false, merged: false, error: 'invalid-target-branch' }
528
+ }
529
+
482
530
  // 1. Verify worktree cleanliness
483
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
+ }
484
535
  if (dirtyCheck.dirty) {
485
536
  return { success: false, merged: false, error: 'worktree-dirty', files: dirtyCheck.files }
486
537
  }
@@ -488,7 +539,7 @@ export async function mergeTaskWorktree({
488
539
  // 2. Locate main repository root
489
540
  let mainRepoDir = ''
490
541
  const revParse = await gitRunner(['rev-parse', '--git-common-dir'], task.worktree)
491
- if (revParse.exitCode === 0 && revParse.stdout.trim()) {
542
+ if ((revParse?.exitCode ?? revParse?.code ?? 0) === 0 && revParse.stdout.trim()) {
492
543
  const commonDir = revParse.stdout.trim()
493
544
  mainRepoDir = commonDir.endsWith('/.git') ? commonDir.slice(0, -5) : commonDir
494
545
  }
@@ -499,16 +550,19 @@ export async function mergeTaskWorktree({
499
550
 
500
551
  // 3. Resolve target merge branch
501
552
  let target = targetBranch
553
+ if (target && !isSafeBranchName(target)) {
554
+ return { success: false, merged: false, error: 'invalid-target-branch' }
555
+ }
502
556
  if (!target) {
503
557
  target = await determineBaseRef(mainRepoDir, gitRunner)
504
558
  }
505
- if (!target) target = 'main'
559
+ if (!target || !isSafeBranchName(target)) target = 'main'
506
560
 
507
561
  // 4. Switch or verify target branch in main repository
508
562
  const currentBranch = await gitRunner(['branch', '--show-current'], mainRepoDir)
509
563
  if (currentBranch.stdout.trim() !== target) {
510
- const checkoutRes = await gitRunner(['checkout', target], mainRepoDir)
511
- if (checkoutRes.exitCode !== 0) {
564
+ const checkoutRes = await gitRunner(['checkout', '--', target], mainRepoDir)
565
+ if ((checkoutRes?.exitCode ?? checkoutRes?.code ?? 0) !== 0) {
512
566
  return { success: false, merged: false, error: 'checkout-target-failed', detail: checkoutRes.stderr }
513
567
  }
514
568
  }
@@ -516,7 +570,7 @@ export async function mergeTaskWorktree({
516
570
  // 5. Execute git merge --no-ff
517
571
  const mergeMsg = `Merge branch '${task.branch}' for task ${task.id}`
518
572
  const mergeRes = await gitRunner(['merge', '--no-ff', task.branch, '-m', mergeMsg], mainRepoDir)
519
- if (mergeRes.exitCode !== 0) {
573
+ if ((mergeRes?.exitCode ?? mergeRes?.code ?? 0) !== 0) {
520
574
  // Roll back merge on conflict
521
575
  await gitRunner(['merge', '--abort'], mainRepoDir)
522
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.20",
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",