@goodandready/dsh-kanban 0.2.23 → 0.2.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -571,12 +571,7 @@ window.__ModuleLoader__.load({
571
571
 
572
572
  async function api(path, options) {
573
573
  const timeoutMs = (options && options.timeout) || 20000
574
- let timeoutSignal
575
- try {
576
- if (!options?.signal) {
577
- timeoutSignal = createTimeoutSignal(timeoutMs)
578
- }
579
- } catch (_) {}
574
+ const timeoutSignal = !options?.signal ? createTimeoutSignal(timeoutMs) : undefined
580
575
 
581
576
  const fetchOpts = Object.assign({
582
577
  headers: { 'Content-Type': 'application/json' },
package/lib/config.js CHANGED
@@ -138,7 +138,12 @@ export function rootOf(config, cwdOf = () => process.cwd()) {
138
138
  export function withDefaults(raw) {
139
139
  const out = {}
140
140
  for (const [key, fallback] of Object.entries(CONFIG_DEFAULTS)) {
141
- const value = raw?.[key]
141
+ let value = raw?.[key]
142
+ if (value && typeof value === 'object' && typeof value.get === 'function') {
143
+ try {
144
+ value = value.get()
145
+ } catch { /* ignore */ }
146
+ }
142
147
  out[key] = typeof value === typeof fallback && value !== null ? value : fallback
143
148
  }
144
149
  return out
package/lib/import.js CHANGED
@@ -66,12 +66,13 @@ export function issueMilestone(issue) {
66
66
  * Fields `branch`, `worktree`, `sessionId`, `model` are NOT populated here:
67
67
  * they are assigned later when work begins. The plugin does not create branches on import.
68
68
  */
69
- export function issueToTask(issue, { board = 'main', column = 'backlog', owner, repo, provider }) {
70
- const p = provider || (typeof issue?.html_url === 'string' && issue.html_url.includes('github.com') ? 'github' : 'gitea')
69
+ export function issueToTask(issue, { board = 'main', column = 'backlog', owner, repo, provider, forge }) {
70
+ const detectedForge = forge || (typeof issue?.html_url === 'string' && issue.html_url.includes('github.com') ? 'github' : 'gitea')
71
71
  return {
72
72
  board,
73
73
  column,
74
- provider: p,
74
+ forge: detectedForge,
75
+ ...(provider && provider !== 'github' && provider !== 'gitea' ? { provider } : {}),
75
76
  title: typeof issue?.title === 'string' ? issue.title : '',
76
77
  body: typeof issue?.body === 'string' ? issue.body : '',
77
78
  labels: Array.isArray(issue?.labels)
@@ -96,10 +97,18 @@ export function issueToTask(issue, { board = 'main', column = 'backlog', owner,
96
97
  * Compared by owner-repo-number tuple to avoid colliding with issue #1 in another repo.
97
98
  * Existing issues are dimmed rather than hidden, as re-importing may be intentional.
98
99
  */
99
- export function markImported(issues, existing, { owner, repo }) {
100
+ export function markImported(issues, existing, { owner, repo, forge }) {
101
+ const targetForge = forge || ''
100
102
  const seen = new Set(
101
103
  (existing ?? [])
102
- .filter((t) => t.owner === owner && t.repo === repo && typeof t.issueNumber === 'number')
104
+ .filter((t) => {
105
+ if (t.owner !== owner || t.repo !== repo || typeof t.issueNumber !== 'number') return false
106
+ if (targetForge) {
107
+ const tf = t.forge || (typeof t.issueUrl === 'string' && t.issueUrl.includes('github.com') ? 'github' : 'gitea')
108
+ return tf === targetForge
109
+ }
110
+ return true
111
+ })
103
112
  .map((t) => t.issueNumber),
104
113
  )
105
114
  return (issues ?? []).map((issue) => ({ ...issue, imported: seen.has(issue?.number) }))
@@ -157,7 +166,7 @@ export async function listImportable({ gitea, store, owner, repo, board = 'main'
157
166
  if (state !== 'ready') return { error: `gitea-${state}`, status: 409 }
158
167
  const issues = await gitea.listIssues({ owner, repo, state: 'open', provider: forge || provider })
159
168
  const existing = store.listTasks({ board })
160
- return { issues: markImported(issues, existing, { owner, repo }) }
169
+ return { issues: markImported(issues, existing, { owner, repo, forge: forge || provider }) }
161
170
  }
162
171
 
163
172
  /** Create a task from an issue. */
@@ -167,9 +176,15 @@ export async function importIssue({ gitea, store, owner, repo, issueNumber, boar
167
176
  if (typeof issueNumber !== 'number') return { error: 'issue-number-required', status: 400 }
168
177
  // Simple board is for non-issue tasks. Importing here would bring branches/PRs into columns unable to display them.
169
178
  if (store.boardKind(board) !== 'project') return { error: 'board-not-for-issues', status: 400 }
170
- const issue = await gitea.getIssue({ owner, repo, index: issueNumber, provider: forge || provider })
179
+ const effectiveForge = forge || (provider === 'github' || provider === 'gitea' ? provider : undefined)
180
+ const effectiveProvider = (provider !== 'github' && provider !== 'gitea') ? provider : undefined
181
+ if (typeof store.findTaskByIssue === 'function') {
182
+ const existing = store.findTaskByIssue({ owner, repo, issueNumber, forge: effectiveForge })
183
+ if (existing) return { task: existing }
184
+ }
185
+ const issue = await gitea.getIssue({ owner, repo, index: issueNumber, provider: effectiveForge || effectiveProvider })
171
186
  if (issue === undefined || issue === null) return { error: 'issue-not-found', status: 404 }
172
- return { task: store.createTask(issueToTask(issue, { board, column, owner, repo, forge: forge || provider })) }
187
+ return { task: store.createTask(issueToTask(issue, { board, column, owner, repo, forge: effectiveForge, provider: effectiveProvider })) }
173
188
  }
174
189
 
175
190
  /** Re-read issue and update task, preserving local state. */
@@ -222,13 +237,15 @@ export async function createProjectTask({
222
237
  targetRepo = wanted
223
238
  }
224
239
 
240
+ const effectiveForge = forge || (provider === 'github' || provider === 'gitea' ? provider : undefined)
241
+ const effectiveProvider = (provider !== 'github' && provider !== 'gitea') ? provider : undefined
225
242
  let issue
226
243
  try {
227
- issue = await gitea.createIssue({ owner, repo: targetRepo, title: name, body, provider: forge || provider })
244
+ issue = await gitea.createIssue({ owner, repo: targetRepo, title: name, body, provider: effectiveForge || effectiveProvider })
228
245
  } catch (error) {
229
246
  // Repository may have already been created; avoid deleting on issue creation failure.
230
247
  return { error: 'issue-not-created', status: 502, detail: String(error?.message ?? error) }
231
248
  }
232
249
 
233
- return { task: store.createTask(issueToTask(issue, { board, column: 'backlog', owner, repo: targetRepo, forge: forge || provider })) }
250
+ return { task: store.createTask(issueToTask(issue, { board, column: 'backlog', owner, repo: targetRepo, forge: effectiveForge, provider: effectiveProvider })) }
234
251
  }
package/lib/index.js CHANGED
@@ -234,7 +234,7 @@ export function apply(ctx, config) {
234
234
  if (typeof settingsSvc?.describe !== 'function') return null
235
235
  try {
236
236
  const row = settingsSvc.describe().find((r) => r?.ns === SETTINGS_NAMESPACE)
237
- if (!row || row.status !== 'ready' || !row.value) return null
237
+ if (!row || !row.value || (row.status !== undefined && row.status !== 'ready')) return null
238
238
  return withDefaults(row.value)
239
239
  } catch (err) {
240
240
  ctx.logger?.warn?.(`dsh-kanban: settings describe failed: ${err?.message || err}`)
@@ -268,14 +268,32 @@ export function apply(ctx, config) {
268
268
  }
269
269
  }
270
270
  }
271
+ const handleDocumentUpdated = (ns) => {
272
+ if (!ns || ns === SETTINGS_NAMESPACE) {
273
+ const served = readServedConfig()
274
+ if (served) {
275
+ effective = served
276
+ rearm()
277
+ }
278
+ }
279
+ }
280
+ const handleVolatileUpdate = (payload) => {
281
+ const served = readServedConfig()
282
+ if (served) {
283
+ effective = served
284
+ rearm()
285
+ return
286
+ }
287
+ applyPatch(payload)
288
+ }
271
289
  if (typeof ctx.effect === 'function') {
272
- ctx.effect(() => ctx.on('loader/volatile-update', applyPatch))
290
+ ctx.effect(() => ctx.on('loader/volatile-update', handleVolatileUpdate))
273
291
  ctx.effect(() => ctx.on('config', applyPatch))
274
- ctx.effect(() => ctx.on('settings/document-updated', applyPatch))
292
+ ctx.effect(() => ctx.on('settings/document-updated', handleDocumentUpdated))
275
293
  } else {
276
- ctx.on('loader/volatile-update', applyPatch)
294
+ ctx.on('loader/volatile-update', handleVolatileUpdate)
277
295
  ctx.on('config', applyPatch)
278
- ctx.on('settings/document-updated', applyPatch)
296
+ ctx.on('settings/document-updated', handleDocumentUpdated)
279
297
  }
280
298
  }
281
299
 
@@ -1142,6 +1160,9 @@ export function apply(ctx, config) {
1142
1160
  workspaceRegistry: ctx.get('workspaceRegistry'),
1143
1161
  force: input?.force === true,
1144
1162
  cleanupTaskWorktree,
1163
+ config: getConfig(),
1164
+ repoDir: rootOf(getConfig()).path,
1165
+ cwdOf: () => rootOf(getConfig()).path,
1145
1166
  })
1146
1167
  writeJson(res, resObj.status, resObj.body)
1147
1168
  return
@@ -1209,6 +1230,9 @@ export function apply(ctx, config) {
1209
1230
  }
1210
1231
  if (req.method === 'POST' && action === 'report') {
1211
1232
  const resObj = handleTaskReport({ store, task: store.getTask(id), input })
1233
+ if (resObj?.body?.task || resObj?.body?.report) {
1234
+ eventHub.broadcast('task:change', { taskId: id, action: 'updated' })
1235
+ }
1212
1236
  writeJson(res, resObj.status, resObj.body)
1213
1237
  return
1214
1238
  }
@@ -1219,6 +1243,9 @@ export function apply(ctx, config) {
1219
1243
  }
1220
1244
  if (req.method === 'POST' && action === 'comments') {
1221
1245
  const resObj = handleAddTaskComment({ store, task: store.getTask(id), input })
1246
+ if (resObj?.body?.task || resObj?.status === 200) {
1247
+ eventHub.broadcast('task:change', { taskId: id, action: 'updated', task: store.getTask(id) })
1248
+ }
1222
1249
  writeJson(res, resObj.status, resObj.body)
1223
1250
  return
1224
1251
  }
@@ -1310,11 +1337,19 @@ export function apply(ctx, config) {
1310
1337
  return
1311
1338
  }
1312
1339
  if (req.method === 'POST' && action === 'archive') {
1313
- reply(res, setArchived({ store, id, archived: true }))
1340
+ const resObj = setArchived({ store, id, archived: true })
1341
+ if (resObj?.task) {
1342
+ eventHub.broadcast('task:change', { taskId: id, action: 'archived' })
1343
+ }
1344
+ reply(res, resObj)
1314
1345
  return
1315
1346
  }
1316
1347
  if (req.method === 'POST' && action === 'restore') {
1317
- reply(res, setArchived({ store, id, archived: false }))
1348
+ const resObj = setArchived({ store, id, archived: false })
1349
+ if (resObj?.task) {
1350
+ eventHub.broadcast('task:change', { taskId: id, action: 'restored' })
1351
+ }
1352
+ reply(res, resObj)
1318
1353
  return
1319
1354
  }
1320
1355
  if (req.method === 'POST' && action === 'queue') {
@@ -1330,11 +1365,19 @@ export function apply(ctx, config) {
1330
1365
  return
1331
1366
  }
1332
1367
  if (req.method === 'POST' && action === 'note') {
1333
- reply(res, appendNote({ store, id, input }))
1368
+ const out = appendNote({ store, id, input })
1369
+ if (out?.task || out?.text) {
1370
+ eventHub.broadcast('task:change', { taskId: id, action: 'updated', task: store.getTask(id) })
1371
+ }
1372
+ reply(res, out)
1334
1373
  return
1335
1374
  }
1336
- if (req.method === 'PATCH' && action === undefined) {
1337
- reply(res, updateTask({ store, id, input }))
1375
+ if (req.method === 'PATCH' && (action === undefined || action === '')) {
1376
+ const resObj = updateTask({ store, id, input })
1377
+ if (resObj?.task) {
1378
+ eventHub.broadcast('task:change', { taskId: id, action: 'updated' })
1379
+ }
1380
+ reply(res, resObj)
1338
1381
  return
1339
1382
  }
1340
1383
  writeJson(res, 405, { error: 'method-not-allowed' })
@@ -1380,9 +1423,10 @@ export function apply(ctx, config) {
1380
1423
  try {
1381
1424
  const input = await readBody(req)
1382
1425
  reply(res, await importIssue({
1383
- gitea, store,
1426
+ gitea: git, store,
1384
1427
  owner: input.owner, repo: input.repo, issueNumber: input.issueNumber,
1385
1428
  board: input.board ?? 'main', column: input.column ?? 'backlog',
1429
+ forge: input.forge || input.provider,
1386
1430
  }))
1387
1431
  } catch (error) {
1388
1432
  writeJson(res, error?.status ?? 500, { error: 'bad-request' })
package/lib/intake.js CHANGED
@@ -41,7 +41,7 @@ export function watchedRepos({ config, owner }) {
41
41
  *
42
42
  * @returns {{take: boolean, why: string}} `why` states reason on rejection.
43
43
  */
44
- export function shouldTake({ store, owner, repo, issue }) {
44
+ export function shouldTake({ store, owner, repo, issue, forge }) {
45
45
  const number = issue?.number
46
46
  if (typeof number !== 'number') return { take: false, why: 'missing-number' }
47
47
  // Pull request in Gitea is also an issue, but not a standalone board card:
@@ -50,10 +50,10 @@ export function shouldTake({ store, owner, repo, issue }) {
50
50
  return { take: false, why: 'pull-request-ignored' }
51
51
  }
52
52
  if (issue?.state === 'closed') return { take: false, why: 'issue-closed' }
53
- if (store.findTaskByIssue({ owner, repo, issueNumber: number }) !== undefined) {
53
+ if (store.findTaskByIssue({ owner, repo, issueNumber: number, forge }) !== undefined) {
54
54
  return { take: false, why: 'already-exists' }
55
55
  }
56
- if (store.isDismissed({ owner, repo, issueNumber: number })) {
56
+ if (store.isDismissed({ owner, repo, issueNumber: number, forge })) {
57
57
  return { take: false, why: 'tombstone-ignored' }
58
58
  }
59
59
  return { take: true, why: '' }
package/lib/launcher.js CHANGED
@@ -163,7 +163,7 @@ export async function obtainAgent({
163
163
  agentPreset, permission, permissions, gitRunner, workspaceRegistry,
164
164
  reasoningEffort,
165
165
  }) {
166
- const chosenPerm = permission || task?.permission || ''
166
+ const chosenPerm = permission || task?.permission || config?.sessionDefaultPermission || ""
167
167
  const effort = reasoningEffort || task?.reasoningEffort || undefined
168
168
  const agentOptions = { provider, model, ...(effort ? { reasoningEffort: effort } : {}) }
169
169
 
@@ -210,7 +210,17 @@ export async function obtainAgent({
210
210
  let workspaceId = task?.workspaceId || undefined
211
211
  let worktreeCreated = false
212
212
 
213
- const isGit = await isGitRepo(cwd, gitRunner)
213
+ let isGit = false
214
+ try {
215
+ isGit = await isGitRepo(cwd, gitRunner)
216
+ } catch (err) {
217
+ if (config?.worktreeIsolation !== false) {
218
+ const error = new Error(`worktree-creation-failed: git probe failed: ${err?.message || err}`)
219
+ error.key = 'worktree-creation-failed'
220
+ error.status = 500
221
+ throw error
222
+ }
223
+ }
214
224
  if (config?.worktreeIsolation !== false && isGit) {
215
225
  let created
216
226
  try {
@@ -331,6 +341,9 @@ export async function runTask({
331
341
  }) {
332
342
  const effort = reasoningEffort || task?.reasoningEffort || undefined
333
343
 
344
+ // Gate check must reject unconfirmed elevated tasks before queueing or state changes (#328)
345
+ verifyPermissionGate({ task, permission, config })
346
+
334
347
  // Concurrency limit check (#214, #330)
335
348
  const maxConcurrent = typeof config?.maxConcurrentSessions === 'number' ? config.maxConcurrentSessions : 3
336
349
  if (maxConcurrent > 0) {
@@ -360,8 +373,6 @@ export async function runTask({
360
373
  }
361
374
  }
362
375
 
363
- verifyPermissionGate({ task, permission, config })
364
-
365
376
  reserveSessionSlot(task.id)
366
377
  let taken
367
378
  try {
@@ -370,9 +381,6 @@ export async function runTask({
370
381
  agentPreset, permission, permissions, gitRunner, workspaceRegistry,
371
382
  reasoningEffort: effort,
372
383
  })
373
- } finally {
374
- releaseSessionSlot(task.id)
375
- }
376
384
 
377
385
  if (task.queuedAt > 0 || task.queuedRequest) {
378
386
  store.updateTask(task.id, { queuedAt: 0, queuedRequest: '' })
@@ -455,6 +463,9 @@ export async function runTask({
455
463
  }
456
464
 
457
465
  return { sessionId: taken.sessionId, mode: revivalKind(taken.mode), task: current }
466
+ } finally {
467
+ releaseSessionSlot(task.id)
468
+ }
458
469
  }
459
470
 
460
471
  /** Build prompt text: custom user message if provided, otherwise standard template. */
@@ -552,9 +563,6 @@ export async function runBatch({
552
563
  agents, task: list[0], config, provider, model, mintSessionId, cwdOf, logger,
553
564
  agentPreset, permission, permissions,
554
565
  })
555
- } finally {
556
- releaseSessionSlot(list[0].id)
557
- }
558
566
 
559
567
  await taken.agent.whenIdle()
560
568
  const own = typeof text === 'string' ? text.trim() : ''
@@ -598,6 +606,9 @@ export async function runBatch({
598
606
  })
599
607
 
600
608
  return { sessionId: taken.sessionId, mode: taken.mode, started: started.length, tasks: started }
609
+ } finally {
610
+ releaseSessionSlot(list[0].id)
611
+ }
601
612
  }
602
613
 
603
614
  /**
@@ -639,7 +650,7 @@ export function unqueueTask({ store, task }) {
639
650
  if (typeof task.queuedAt !== 'number' || task.queuedAt <= 0) {
640
651
  return { error: 'task-not-queued', status: 400 }
641
652
  }
642
- store.updateTask(task.id, { queuedAt: 0, sessionId: null })
653
+ store.updateTask(task.id, { queuedAt: 0, sessionId: null, queuedRequest: '' })
643
654
  store.addTransition({
644
655
  taskId: task.id, fromCol: task.column, toCol: task.column, source: 'manual',
645
656
  detail: 'removed from queue; message already sent to agent',
@@ -652,7 +663,12 @@ export function unqueueTask({ store, task }) {
652
663
  */
653
664
  export function countActiveSessions({ store, agents }) {
654
665
  const sessions = liveSessions({ store, agents })
655
- const activeCount = sessions.filter((s) => s.status === 'running' || s.status === 'busy' || s.status === 'thinking').length
666
+ const activeCount = sessions.filter((s) =>
667
+ s.status === 'running' ||
668
+ s.status === 'busy' ||
669
+ s.status === 'thinking' ||
670
+ s.tasks.some((t) => t.workflowActive)
671
+ ).length
656
672
  return activeCount + activeReservations.size
657
673
  }
658
674
 
@@ -679,10 +695,12 @@ export function liveSessions({ store, agents }) {
679
695
  if (live.origin === 'subagent' || live.parentId !== undefined || live.meta?.origin === 'subagent' || live.isSubagent) {
680
696
  continue
681
697
  }
698
+ const isWorkflowActive = task.workflowStatus === 'running' || (typeof task.workflowRunId === 'string' && task.workflowRunId !== '' && task.workflowStatus !== 'completed' && task.workflowStatus !== 'failed' && task.workflowStatus !== 'cancelled')
682
699
  if (!byId.has(id)) byId.set(id, { sessionId: id, status: live.status, tasks: [] })
683
700
  byId.get(id).tasks.push({
684
701
  id: task.id, title: task.title, repo: task.repo, issueNumber: task.issueNumber,
685
702
  queued: typeof task.queuedAt === 'number' && task.queuedAt > 0,
703
+ workflowActive: isWorkflowActive,
686
704
  })
687
705
  }
688
706
  return [...byId.values()]
@@ -705,34 +723,36 @@ export async function dispatchNextQueuedTask({
705
723
  const queued = typeof store.listQueuedTasks === 'function' ? store.listQueuedTasks() : []
706
724
  if (queued.length === 0) return null
707
725
 
708
- const nextTask = queued[0]
709
- let queuedParams = {}
710
- if (typeof nextTask.queuedRequest === 'string' && nextTask.queuedRequest) {
711
- try { queuedParams = JSON.parse(nextTask.queuedRequest) } catch { /* ignore */ }
712
- }
713
- try {
714
- return await runTask({
715
- agents,
716
- store,
717
- task: nextTask,
718
- config,
719
- provider: queuedParams.provider || nextTask.provider || undefined,
720
- model: queuedParams.model || nextTask.model || undefined,
721
- permission: queuedParams.permission || nextTask.permission || undefined,
722
- reasoningEffort: queuedParams.reasoningEffort || nextTask.reasoningEffort || undefined,
723
- text: queuedParams.text || undefined,
724
- agentPreset: queuedParams.agentPreset || agentPreset,
725
- mintSessionId: mintSessionId || (() => `kanban-${nextTask.id}-${randomUUID()}`),
726
- createMessage: createMessage || ((msg) => msg),
727
- cwdOf,
728
- logger,
729
- permissions,
730
- workspaceRegistry,
731
- claimAs: claimAs || 'queue-dispatcher',
732
- gitRunner,
733
- })
734
- } catch (err) {
735
- logger?.warn?.(`dsh-kanban: failed to start queued task ${nextTask.id}: ${err?.message}`)
736
- return null
726
+ for (const nextTask of queued) {
727
+ let queuedParams = {}
728
+ if (typeof nextTask.queuedRequest === 'string' && nextTask.queuedRequest) {
729
+ try { queuedParams = JSON.parse(nextTask.queuedRequest) } catch { /* ignore */ }
730
+ }
731
+ try {
732
+ return await runTask({
733
+ agents,
734
+ store,
735
+ task: nextTask,
736
+ config,
737
+ provider: queuedParams.provider || nextTask.provider || undefined,
738
+ model: queuedParams.model || nextTask.model || undefined,
739
+ permission: queuedParams.permission || nextTask.permission || undefined,
740
+ reasoningEffort: queuedParams.reasoningEffort || nextTask.reasoningEffort || undefined,
741
+ text: queuedParams.text || undefined,
742
+ agentPreset: queuedParams.agentPreset || agentPreset,
743
+ mintSessionId: mintSessionId || (() => `kanban-${nextTask.id}-${randomUUID()}`),
744
+ createMessage: createMessage || ((msg) => msg),
745
+ cwdOf,
746
+ logger,
747
+ permissions,
748
+ workspaceRegistry,
749
+ claimAs: claimAs || 'queue-dispatcher',
750
+ gitRunner,
751
+ })
752
+ } catch (err) {
753
+ logger?.warn?.(`dsh-kanban: failed to start queued task ${nextTask.id}: ${err?.message}`)
754
+ unqueueTask({ store, task: nextTask })
755
+ }
737
756
  }
757
+ return null
738
758
  }
package/lib/order.js CHANGED
@@ -30,4 +30,19 @@ export function keyBetween(a, b) {
30
30
  return generateKeyBetween(a ?? null, b ?? null)
31
31
  }
32
32
 
33
+ export function isValidOrderKey(key) {
34
+ if (typeof key !== 'string' || !key || /[^0-9a-zA-Z]/.test(key)) return false
35
+ try {
36
+ generateKeyBetween(null, key)
37
+ return true
38
+ } catch {
39
+ try {
40
+ generateKeyBetween(key, null)
41
+ return true
42
+ } catch {
43
+ return false
44
+ }
45
+ }
46
+ }
47
+
33
48
  export { generateKeyBetween, generateNKeysBetween }
@@ -62,21 +62,18 @@ 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('цель:')) {
65
+ if (lower.startsWith('goal:')) {
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('done:') || lower.startsWith('прогресс:')) {
69
+ } else if (lower.startsWith('progress:') || lower.startsWith('done:')) {
70
70
  currentKey = 'progress'
71
71
  const val = line.slice(line.indexOf(':') + 1).trim()
72
72
  if (val) sections.progress.push(val)
73
73
  } else if (
74
74
  lower.startsWith('next:') ||
75
75
  lower.startsWith('next steps:') ||
76
- lower.startsWith('next step:') ||
77
- lower.startsWith('следующие шаги:') ||
78
- lower.startsWith('шаги:') ||
79
- lower.startsWith('дальше:')
76
+ lower.startsWith('next step:')
80
77
  ) {
81
78
  currentKey = 'next'
82
79
  const val = line.slice(line.indexOf(':') + 1).trim()
package/lib/routes.js CHANGED
@@ -238,9 +238,7 @@ export function updateTask({ store, id, input }) {
238
238
  patch.permissionConfirmedTier = ''
239
239
  }
240
240
  }
241
- if (typeof input?.permissionConfirmedTier === 'string') {
242
- patch.permissionConfirmedTier = input.permissionConfirmedTier
243
- }
241
+
244
242
  if (typeof input?.reasoningEffort === 'string') {
245
243
  const val = input.reasoningEffort.trim().toLowerCase()
246
244
  if (['', 'low', 'medium', 'high', 'default', 'none', 'minimal', 'thorough'].includes(val)) {
@@ -414,7 +412,13 @@ export function importBoard({ store, input }) {
414
412
  const doImport = () => {
415
413
  for (const board of Array.isArray(input.boards) ? input.boards : []) {
416
414
  if (typeof board?.id !== 'string' || board.id === '') continue
417
- store.createBoard({ id: board.id, title: board.title, kind: board.kind })
415
+ store.createBoard({
416
+ id: board.id,
417
+ title: board.title,
418
+ kind: board.kind,
419
+ position: board.position,
420
+ createdAt: board.createdAt,
421
+ })
418
422
  }
419
423
 
420
424
  for (const task of input.tasks) {
@@ -524,15 +528,23 @@ export function exportTasksCsv({ store, board = 'main' }) {
524
528
  /**
525
529
  * Clean up task worktree.
526
530
  */
527
- export async function handleCleanupWorktree({ store, task, workspaceRegistry, force, cleanupTaskWorktree }) {
531
+ export async function handleCleanupWorktree({
532
+ store, task, workspaceRegistry, force, cleanupTaskWorktree,
533
+ config, repoDir, cwdOf, env,
534
+ }) {
528
535
  if (!task) return { status: 404, body: { error: 'task-not-found' } }
529
536
  const out = await cleanupTaskWorktree({
530
537
  store,
531
538
  task,
532
539
  workspaceRegistry,
533
540
  force: force === true,
541
+ config,
542
+ repoDir,
543
+ cwdOf,
544
+ env,
534
545
  })
535
- return { status: 200, body: out }
546
+ const status = out.status || (out.error ? (out.error === 'unfenced-worktree-path' ? 403 : 500) : 200)
547
+ return { status, body: out }
536
548
  }
537
549
 
538
550
  /**
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, generateNKeysBetween } from './order.js'
16
+ import { keyBetween, generateNKeysBetween, isValidOrderKey } from './order.js'
17
17
  import { DEFAULT_BOARDS, normalizeKind } from './config.js'
18
18
 
19
19
  const SCHEMA = `
@@ -73,6 +73,9 @@ CREATE TABLE IF NOT EXISTS tasks (
73
73
  isEpic INTEGER NOT NULL DEFAULT 0,
74
74
  workflowRunId TEXT NOT NULL DEFAULT '',
75
75
  workflowStatus TEXT NOT NULL DEFAULT '',
76
+ forge TEXT NOT NULL DEFAULT '',
77
+ permissionConfirmedTier TEXT NOT NULL DEFAULT '',
78
+ queuedRequest TEXT NOT NULL DEFAULT '',
76
79
  updatedAt INTEGER NOT NULL
77
80
  );
78
81
  CREATE INDEX IF NOT EXISTS tasks_board_col ON tasks(board, col, position);
@@ -91,8 +94,9 @@ CREATE TABLE IF NOT EXISTS dismissed (
91
94
  owner TEXT NOT NULL,
92
95
  repo TEXT NOT NULL,
93
96
  issueNumber INTEGER NOT NULL,
97
+ forge TEXT NOT NULL DEFAULT '',
94
98
  at INTEGER NOT NULL,
95
- PRIMARY KEY (owner, repo, issueNumber)
99
+ PRIMARY KEY (owner, repo, issueNumber, forge)
96
100
  );
97
101
  `
98
102
 
@@ -103,7 +107,7 @@ const WRITABLE = [
103
107
  'labelColors', 'queuedAt', 'createdAt', 'author', 'assignee', 'milestone', 'priority', 'dueAt',
104
108
  'cron', 'nextRunAt', 'lastRunAt', 'progressDump', 'permission', 'reasoningEffort', 'permissionConfirmedAt',
105
109
  'runs', 'checklist', 'report', 'comments', 'rejectReason', 'rejectedAt', 'parentId', 'isEpic',
106
- 'workflowRunId', 'workflowStatus', 'permissionConfirmedTier', 'forge',
110
+ 'workflowRunId', 'workflowStatus', 'permissionConfirmedTier', 'forge', 'queuedRequest',
107
111
  ]
108
112
 
109
113
  /** Valid transition sources; unexpected values are rejected to preserve log integrity. */
@@ -244,6 +248,7 @@ export function openStore({ dir }) {
244
248
  ['workflowStatus', "TEXT NOT NULL DEFAULT ''"],
245
249
  ['forge', "TEXT NOT NULL DEFAULT ''"],
246
250
  ['permissionConfirmedTier', "TEXT NOT NULL DEFAULT ''"],
251
+ ['queuedRequest', "TEXT NOT NULL DEFAULT ''"],
247
252
  ]) {
248
253
  try { db.exec(`ALTER TABLE tasks ADD COLUMN ${column} ${definition}`) } catch { /* column already exists */ }
249
254
  }
@@ -283,12 +288,35 @@ export function openStore({ dir }) {
283
288
  }
284
289
  }
285
290
  }
291
+ const boardRows = db.prepare('SELECT id, position FROM boards ORDER BY position ASC, id ASC').all()
292
+ if (boardRows.length > 0) {
293
+ const updateBoardPos = db.prepare('UPDATE boards SET position = ? WHERE id = ?')
294
+ const newBoardKeys = generateNKeysBetween(null, null, boardRows.length)
295
+ for (let i = 0; i < boardRows.length; i++) {
296
+ updateBoardPos.run(newBoardKeys[i], boardRows[i].id)
297
+ }
298
+ }
286
299
  db.prepare("INSERT OR REPLACE INTO meta (key, val) VALUES ('fractional_indexing_migrated', '1')").run()
287
300
  db.exec('COMMIT')
288
301
  } catch (e) {
289
302
  try { db.exec('ROLLBACK') } catch { /* ignore */ }
290
303
  }
291
304
  }
305
+ // Ensure board order keys are valid, even if fractional_indexing_migrated marker already exists (#345)
306
+ const boardRows = db.prepare('SELECT id, position FROM boards ORDER BY position ASC, id ASC').all()
307
+ if (boardRows.length > 0 && boardRows.some((b) => !isValidOrderKey(b.position))) {
308
+ try {
309
+ db.exec('BEGIN TRANSACTION')
310
+ const updateBoardPos = db.prepare('UPDATE boards SET position = ? WHERE id = ?')
311
+ const newBoardKeys = generateNKeysBetween(null, null, boardRows.length)
312
+ for (let i = 0; i < boardRows.length; i++) {
313
+ updateBoardPos.run(newBoardKeys[i], boardRows[i].id)
314
+ }
315
+ db.exec('COMMIT')
316
+ } catch {
317
+ try { db.exec('ROLLBACK') } catch { /* ignore */ }
318
+ }
319
+ }
292
320
  db.exec('PRAGMA synchronous = NORMAL')
293
321
  try { db.exec('PRAGMA optimize') } catch { /* pragma optimize best-effort */ }
294
322
 
@@ -303,16 +331,16 @@ export function openStore({ dir }) {
303
331
  labelColors, author, assignee, milestone, priority, dueAt, columnAt, createdAt, updatedAt,
304
332
  cron, nextRunAt, lastRunAt, progressDump, permission, permissionConfirmedAt, runs,
305
333
  checklist, report, comments, rejectReason, rejectedAt, reasoningEffort, parentId, isEpic,
306
- workflowRunId, workflowStatus)
307
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`),
334
+ workflowRunId, workflowStatus, forge, queuedRequest)
335
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`),
308
336
  restoreTask: db.prepare(`INSERT INTO tasks
309
337
  (id, board, col, position, title, body, owner, repo, issueNumber, issueUrl, externalRef,
310
338
  labels, branch, worktree, workspaceId, sessionId, model, provider, waiting, syncedAt,
311
339
  labelColors, author, assignee, milestone, priority, dueAt, columnAt, createdAt, updatedAt,
312
340
  cron, nextRunAt, lastRunAt, progressDump, permission, permissionConfirmedAt, permissionConfirmedTier, runs,
313
341
  checklist, report, comments, rejectReason, rejectedAt, reasoningEffort, parentId, isEpic,
314
- workflowRunId, workflowStatus, archivedAt, queuedAt, plan, forge)
315
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
342
+ workflowRunId, workflowStatus, archivedAt, queuedAt, plan, forge, queuedRequest)
343
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
316
344
  ON CONFLICT(id) DO UPDATE SET
317
345
  board=excluded.board, col=excluded.col, position=excluded.position, title=excluded.title,
318
346
  body=excluded.body, owner=excluded.owner, repo=excluded.repo, issueNumber=excluded.issueNumber,
@@ -330,7 +358,8 @@ export function openStore({ dir }) {
330
358
  rejectReason=excluded.rejectReason, rejectedAt=excluded.rejectedAt,
331
359
  reasoningEffort=excluded.reasoningEffort, parentId=excluded.parentId, isEpic=excluded.isEpic,
332
360
  workflowRunId=excluded.workflowRunId, workflowStatus=excluded.workflowStatus,
333
- archivedAt=excluded.archivedAt, queuedAt=excluded.queuedAt, plan=excluded.plan, forge=excluded.forge`),
361
+ archivedAt=excluded.archivedAt, queuedAt=excluded.queuedAt, plan=excluded.plan, forge=excluded.forge,
362
+ queuedRequest=excluded.queuedRequest`),
334
363
  restoreTransition: db.prepare(`INSERT INTO transitions
335
364
  (taskId, fromCol, toCol, source, detail, at)
336
365
  VALUES (?, ?, ?, ?, ?, ?)`),
@@ -344,7 +373,7 @@ export function openStore({ dir }) {
344
373
  archived: db.prepare('SELECT * FROM tasks WHERE archivedAt > 0 ORDER BY archivedAt DESC'),
345
374
  archivable: db.prepare(
346
375
  "SELECT * FROM tasks WHERE archivedAt = 0 AND col = 'done' AND columnAt > 0 AND columnAt <= ?"),
347
- dismiss: db.prepare('INSERT OR REPLACE INTO dismissed (owner, repo, issueNumber, at) VALUES (?, ?, ?, ?)'),
376
+ dismiss: db.prepare('INSERT OR REPLACE INTO dismissed (owner, repo, issueNumber, forge, at) VALUES (?, ?, ?, ?, ?)'),
348
377
  isDismissed: db.prepare('SELECT 1 FROM dismissed WHERE owner = ? AND repo = ? AND issueNumber = ?'),
349
378
  isDismissedWithForge: db.prepare(`SELECT 1 FROM dismissed WHERE owner = ? AND repo = ? AND issueNumber = ? AND (forge = ? OR (forge = '' AND ? = 'gitea'))`),
350
379
  byIssue: db.prepare('SELECT * FROM tasks WHERE owner = ? AND repo = ? AND issueNumber = ?'),
@@ -365,12 +394,19 @@ export function openStore({ dir }) {
365
394
  "SELECT COUNT(*) AS total, SUM(CASE WHEN col = 'done' THEN 1 ELSE 0 END) AS completed FROM tasks WHERE parentId = ? AND archivedAt = 0"),
366
395
  }
367
396
 
368
- function ensureBoard(id, title, kind) {
397
+ function ensureBoard(id, title, kind, position, createdAt) {
369
398
  const found = q.boardById.get(id)
370
- if (found !== undefined) return found
399
+ if (found !== undefined) {
400
+ if (position !== undefined || createdAt !== undefined || title !== undefined || kind !== undefined) {
401
+ db.prepare('UPDATE boards SET title = ?, kind = ?, position = ?, createdAt = ? WHERE id = ?')
402
+ .run(title ?? found.title, normalizeKind(kind ?? found.kind), position ?? found.position, createdAt ?? found.createdAt, id)
403
+ }
404
+ return q.boardById.get(id)
405
+ }
371
406
  const last = q.lastBoard.get()
372
- const position = keyBetween(last?.position, undefined)
373
- q.insertBoard.run(id, title, normalizeKind(kind), position, Date.now())
407
+ const pos = isValidOrderKey(position) ? position : keyBetween(last?.position, undefined)
408
+ const created = typeof createdAt === 'number' && createdAt > 0 ? createdAt : Date.now()
409
+ q.insertBoard.run(id, title, normalizeKind(kind), pos, created)
374
410
  return q.boardById.get(id)
375
411
  }
376
412
 
@@ -440,8 +476,8 @@ export function openStore({ dir }) {
440
476
  return q.boards.all()
441
477
  },
442
478
 
443
- createBoard({ id, title, kind }) {
444
- return ensureBoard(id, title ?? id, kind)
479
+ createBoard({ id, title, kind, position, createdAt }) {
480
+ return ensureBoard(id, title ?? id, kind, position, createdAt)
445
481
  },
446
482
 
447
483
  /** Board kind. Unrecognized kinds default to 'project'. */
@@ -529,6 +565,8 @@ export function openStore({ dir }) {
529
565
  input.isEpic ? 1 : 0,
530
566
  input.workflowRunId ?? '',
531
567
  input.workflowStatus ?? '',
568
+ input.forge ?? '',
569
+ input.queuedRequest ?? '',
532
570
  )
533
571
  return rowToTask(q.taskById.get(id))
534
572
  },
@@ -631,12 +669,17 @@ export function openStore({ dir }) {
631
669
  const checklist = JSON.stringify(Array.isArray(task.checklist) ? task.checklist : [])
632
670
  const report = JSON.stringify(task.report && typeof task.report === 'object' ? task.report : {})
633
671
  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')
672
+ const forge = typeof task.forge === 'string'
673
+ ? task.forge
674
+ : (typeof task.issueUrl === 'string' && task.issueUrl.includes('github.com') ? 'github' : (task.owner && task.repo && task.issueNumber ? 'gitea' : ''))
675
+ const targetBoard = task.board || 'main'
676
+ const targetCol = task.column || task.col || 'backlog'
677
+ const position = isValidOrderKey(task.position) ? task.position : positionFor(targetBoard, targetCol, undefined, undefined)
635
678
  q.restoreTask.run(
636
679
  task.id,
637
- task.board || 'main',
638
- task.column || task.col || 'backlog',
639
- task.position || keyBetween(undefined, undefined),
680
+ targetBoard,
681
+ targetCol,
682
+ position,
640
683
  task.title || '',
641
684
  task.body || '',
642
685
  task.owner ?? null,
@@ -650,7 +693,7 @@ export function openStore({ dir }) {
650
693
  task.workspaceId || '',
651
694
  task.sessionId ?? null,
652
695
  task.model ?? null,
653
- task.provider ?? 'gitea',
696
+ task.provider !== undefined ? task.provider : null,
654
697
  task.waiting ? 1 : 0,
655
698
  task.syncedAt || 0,
656
699
  colors,
@@ -684,6 +727,7 @@ export function openStore({ dir }) {
684
727
  task.queuedAt || 0,
685
728
  task.plan || '',
686
729
  forge,
730
+ task.queuedRequest || '',
687
731
  )
688
732
  return rowToTask(q.taskById.get(task.id))
689
733
  },
@@ -755,10 +799,10 @@ export function openStore({ dir }) {
755
799
  },
756
800
 
757
801
  deleteTask(id) {
758
- // Record deleted Gitea tasks as dismissed to prevent the intake loop from re-importing them.
802
+ // Record deleted tasks as dismissed to prevent the intake loop from re-importing them.
759
803
  const task = q.taskById.get(id)
760
804
  if (task?.owner && task?.repo && typeof task.issueNumber === 'number') {
761
- q.dismiss.run(task.owner, task.repo, task.issueNumber, Date.now())
805
+ q.dismiss.run(task.owner, task.repo, task.issueNumber, task.forge || '', Date.now())
762
806
  }
763
807
  q.deleteTask.run(id)
764
808
  },
package/lib/sync.js CHANGED
@@ -40,9 +40,10 @@ export async function withBackoff(fn, { retries = 2, baseMs = 50, maxMs = 500 }
40
40
  * would lead to erroneous column transitions. If reading fails, the repository is skipped.
41
41
  */
42
42
  async function readRepo({ gitea, owner, repo, forge }) {
43
+ const provider = forge || undefined
43
44
  const [pulls, branches] = await Promise.all([
44
- gitea.listPulls({ owner, repo, state: 'all' }),
45
- gitea.listBranches({ owner, repo }),
45
+ gitea.listPulls({ owner, repo, state: 'all', provider }),
46
+ gitea.listBranches({ owner, repo, provider }),
46
47
  ])
47
48
  return { pulls, branches }
48
49
  }
@@ -489,7 +489,7 @@ export class WorkflowBridge {
489
489
  repo: task.repo,
490
490
  index: task.issueNumber,
491
491
  body: commentBody,
492
- provider: task.provider,
492
+ provider: task.forge || (typeof task.issueUrl === 'string' && task.issueUrl.includes('github.com') ? 'github' : 'gitea'),
493
493
  })
494
494
  } catch (err) {
495
495
  this.logger?.warn?.(`dsh-kanban: failed to comment on issue ${task.repo}#${task.issueNumber}: ${err.message}`)
package/lib/worktree.js CHANGED
@@ -28,7 +28,7 @@ export async function defaultGitRunner(args, cwd) {
28
28
  const { stdout, stderr } = await execFileAsync('git', args, {
29
29
  cwd,
30
30
  maxBuffer: 10 * 1024 * 1024,
31
- env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
31
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0', LC_ALL: 'C' },
32
32
  })
33
33
  return { stdout: stdout.trim(), stderr: stderr.trim(), code: 0, exitCode: 0 }
34
34
  } catch (err) {
@@ -136,8 +136,18 @@ export async function isGitRepo(repoDir, gitRunner = defaultGitRunner) {
136
136
  try {
137
137
  const res = await gitRunner(['rev-parse', '--is-inside-work-tree'], repoDir)
138
138
  return res.stdout?.trim() === 'true'
139
- } catch {
140
- return false
139
+ } catch (err) {
140
+ const msg = String(err?.stderr || err?.message || '').toLowerCase()
141
+ if (
142
+ msg.includes('not a git repo') ||
143
+ msg.includes('not a git repository') ||
144
+ msg.includes('не найден git репозиторий') ||
145
+ msg.includes('не является git-репозиторием') ||
146
+ (err?.code === 'ENOENT' && repoDir && !existsSync(repoDir))
147
+ ) {
148
+ return false
149
+ }
150
+ throw err
141
151
  }
142
152
  }
143
153
 
@@ -371,17 +381,58 @@ export async function cleanupTaskWorktree({
371
381
  if (!current.worktree) return { noop: true, removed: false }
372
382
 
373
383
  const root = resolveWorktreeRoot(config, env, cwdOf)
384
+ const customRoot = typeof config?.worktreeRoot === 'string' && config.worktreeRoot.trim() !== ''
385
+ ? root
386
+ : undefined
374
387
  const candidateRoots = [
375
388
  root,
376
389
  repoDir,
377
- typeof cwdOf === 'function' ? cwdOf() : process.cwd(),
378
- process.cwd(),
390
+ typeof cwdOf === 'function' ? cwdOf() : (!customRoot && !repoDir ? process.cwd() : undefined),
379
391
  ].filter(Boolean)
380
- const isFenced = candidateRoots.some((c) => isPathFenced(current.worktree, c))
392
+ const isFenced = candidateRoots.length === 0 || candidateRoots.some((c) => isPathFenced(current.worktree, c))
381
393
  if (!isFenced) {
382
394
  return { error: 'unfenced-worktree-path', status: 403 }
383
395
  }
384
396
 
397
+ // 1. Verify worktree cleanliness (force must not bypass dirty worktree ban #341)
398
+ const dirtyCheck = await checkWorktreeDirty({ worktreePath: current.worktree, gitRunner })
399
+ if (dirtyCheck.error) {
400
+ return { error: 'worktree-status-error', detail: dirtyCheck.error }
401
+ }
402
+ if (dirtyCheck.dirty) {
403
+ store?.addTransition?.({
404
+ taskId: current.id,
405
+ fromCol: current.column,
406
+ toCol: current.column,
407
+ source: 'session',
408
+ detail: `cleanup deferred: uncommitted files detected: ${dirtyCheck.files.slice(0, 3).join(', ')}`,
409
+ })
410
+ return { error: 'worktree-dirty', files: dirtyCheck.files }
411
+ }
412
+
413
+ // 2. Ownership fencing: ensure worktree is registered in repository before removal (#315)
414
+ let baseRepo = repoDir
415
+ if (!baseRepo && current?.repo) {
416
+ const root = typeof config?.defaultProjectRoot === 'string' && config.defaultProjectRoot.trim() !== ''
417
+ ? config.defaultProjectRoot.trim()
418
+ : (typeof cwdOf === 'function' ? cwdOf() : process.cwd())
419
+ baseRepo = resolve(join(root, current.repo))
420
+ }
421
+ if (!baseRepo) {
422
+ const rev = await gitRunner(['rev-parse', '--git-common-dir'], current.worktree)
423
+ if ((rev?.exitCode ?? rev?.code ?? 0) === 0 && rev?.stdout?.trim()) {
424
+ const cd = rev.stdout.trim()
425
+ baseRepo = cd.endsWith('/.git') ? cd.slice(0, -5) : cd
426
+ }
427
+ }
428
+ if (baseRepo) {
429
+ const registered = await listWorktrees(baseRepo, gitRunner)
430
+ const norm = resolve(current.worktree)
431
+ if (!registered.some((p) => resolve(p) === norm)) {
432
+ return { error: 'worktree-not-registered', status: 403 }
433
+ }
434
+ }
435
+
385
436
  const targetRepoDir = repoDir || current.worktree
386
437
  const result = await removeTaskWorktree({
387
438
  repoDir: targetRepoDir,
@@ -392,15 +443,22 @@ export async function cleanupTaskWorktree({
392
443
  gitRunner,
393
444
  })
394
445
 
395
- if (result.error === 'worktree-dirty') {
396
- store?.addTransition?.({
397
- taskId: current.id,
398
- fromCol: current.column,
399
- toCol: current.column,
400
- source: 'session',
401
- detail: `cleanup deferred: uncommitted files detected: ${result.files.slice(0, 3).join(', ')}`,
402
- })
403
- return { error: 'worktree-dirty', files: result.files }
446
+ if (result.error) {
447
+ if (result.error === 'worktree-dirty') {
448
+ store?.addTransition?.({
449
+ taskId: current.id,
450
+ fromCol: current.column,
451
+ toCol: current.column,
452
+ source: 'session',
453
+ detail: `cleanup deferred: uncommitted files detected: ${result.files.slice(0, 3).join(', ')}`,
454
+ })
455
+ return { error: 'worktree-dirty', files: result.files }
456
+ }
457
+ return { success: false, removed: false, error: result.error, detail: result.detail }
458
+ }
459
+
460
+ if (!result.removed) {
461
+ return { success: false, removed: false, reason: result.reason }
404
462
  }
405
463
 
406
464
  if (workspaceRegistry && current.workspaceId) {
@@ -548,6 +606,21 @@ export async function mergeTaskWorktree({
548
606
  return { success: false, merged: false, error: 'main-repo-not-found' }
549
607
  }
550
608
 
609
+ // Repository ownership fence: main repository name must match task.repo (#315)
610
+ if (task.repo) {
611
+ const expectedRepo = task.repo.trim()
612
+ const baseName = basename(mainRepoDir)
613
+ if (baseName !== expectedRepo && !mainRepoDir.endsWith('/' + expectedRepo) && !mainRepoDir.endsWith(sep + expectedRepo)) {
614
+ return { success: false, merged: false, error: 'foreign-repository-mismatch' }
615
+ }
616
+ }
617
+
618
+ const registered = await listWorktrees(mainRepoDir, gitRunner)
619
+ const normTaskWorktree = resolve(task.worktree)
620
+ if (!registered.some((p) => resolve(p) === normTaskWorktree)) {
621
+ return { success: false, merged: false, error: 'worktree-not-registered' }
622
+ }
623
+
551
624
  // 3. Resolve target merge branch
552
625
  let target = targetBranch
553
626
  if (target && !isSafeBranchName(target)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-kanban",
3
- "version": "0.2.23",
3
+ "version": "0.2.25",
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",
@@ -18,10 +18,6 @@
18
18
  "README.ru.md",
19
19
  "LICENSE"
20
20
  ],
21
- "scripts": {
22
- "test": "node --test test/*.test.mjs",
23
- "compat": "node tools/compat.mjs"
24
- },
25
21
  "keywords": [
26
22
  "dsh",
27
23
  "dsh-plugin",
@@ -93,5 +89,9 @@
93
89
  "@deepseek-ai/dsh-tools": {
94
90
  "optional": true
95
91
  }
92
+ },
93
+ "scripts": {
94
+ "test": "node --test test/*.test.mjs",
95
+ "compat": "node tools/compat.mjs"
96
96
  }
97
- }
97
+ }