@goodandready/dsh-kanban 0.2.24 → 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/import.js +11 -3
- package/lib/index.js +8 -1
- package/lib/intake.js +3 -3
- package/lib/launcher.js +43 -33
- package/lib/routes.js +7 -1
- package/lib/store.js +36 -11
- package/lib/sync.js +3 -2
- package/lib/worktree.js +53 -5
- package/package.json +6 -6
package/lib/import.js
CHANGED
|
@@ -97,10 +97,18 @@ export function issueToTask(issue, { board = 'main', column = 'backlog', owner,
|
|
|
97
97
|
* Compared by owner-repo-number tuple to avoid colliding with issue #1 in another repo.
|
|
98
98
|
* Existing issues are dimmed rather than hidden, as re-importing may be intentional.
|
|
99
99
|
*/
|
|
100
|
-
export function markImported(issues, existing, { owner, repo }) {
|
|
100
|
+
export function markImported(issues, existing, { owner, repo, forge }) {
|
|
101
|
+
const targetForge = forge || ''
|
|
101
102
|
const seen = new Set(
|
|
102
103
|
(existing ?? [])
|
|
103
|
-
.filter((t) =>
|
|
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
|
+
})
|
|
104
112
|
.map((t) => t.issueNumber),
|
|
105
113
|
)
|
|
106
114
|
return (issues ?? []).map((issue) => ({ ...issue, imported: seen.has(issue?.number) }))
|
|
@@ -158,7 +166,7 @@ export async function listImportable({ gitea, store, owner, repo, board = 'main'
|
|
|
158
166
|
if (state !== 'ready') return { error: `gitea-${state}`, status: 409 }
|
|
159
167
|
const issues = await gitea.listIssues({ owner, repo, state: 'open', provider: forge || provider })
|
|
160
168
|
const existing = store.listTasks({ board })
|
|
161
|
-
return { issues: markImported(issues, existing, { owner, repo }) }
|
|
169
|
+
return { issues: markImported(issues, existing, { owner, repo, forge: forge || provider }) }
|
|
162
170
|
}
|
|
163
171
|
|
|
164
172
|
/** Create a task from an issue. */
|
package/lib/index.js
CHANGED
|
@@ -1243,6 +1243,9 @@ export function apply(ctx, config) {
|
|
|
1243
1243
|
}
|
|
1244
1244
|
if (req.method === 'POST' && action === 'comments') {
|
|
1245
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
|
+
}
|
|
1246
1249
|
writeJson(res, resObj.status, resObj.body)
|
|
1247
1250
|
return
|
|
1248
1251
|
}
|
|
@@ -1362,7 +1365,11 @@ export function apply(ctx, config) {
|
|
|
1362
1365
|
return
|
|
1363
1366
|
}
|
|
1364
1367
|
if (req.method === 'POST' && action === 'note') {
|
|
1365
|
-
|
|
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)
|
|
1366
1373
|
return
|
|
1367
1374
|
}
|
|
1368
1375
|
if (req.method === 'PATCH' && (action === undefined || action === '')) {
|
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
|
|
|
@@ -341,6 +341,9 @@ export async function runTask({
|
|
|
341
341
|
}) {
|
|
342
342
|
const effort = reasoningEffort || task?.reasoningEffort || undefined
|
|
343
343
|
|
|
344
|
+
// Gate check must reject unconfirmed elevated tasks before queueing or state changes (#328)
|
|
345
|
+
verifyPermissionGate({ task, permission, config })
|
|
346
|
+
|
|
344
347
|
// Concurrency limit check (#214, #330)
|
|
345
348
|
const maxConcurrent = typeof config?.maxConcurrentSessions === 'number' ? config.maxConcurrentSessions : 3
|
|
346
349
|
if (maxConcurrent > 0) {
|
|
@@ -370,8 +373,6 @@ export async function runTask({
|
|
|
370
373
|
}
|
|
371
374
|
}
|
|
372
375
|
|
|
373
|
-
verifyPermissionGate({ task, permission, config })
|
|
374
|
-
|
|
375
376
|
reserveSessionSlot(task.id)
|
|
376
377
|
let taken
|
|
377
378
|
try {
|
|
@@ -662,7 +663,12 @@ export function unqueueTask({ store, task }) {
|
|
|
662
663
|
*/
|
|
663
664
|
export function countActiveSessions({ store, agents }) {
|
|
664
665
|
const sessions = liveSessions({ store, agents })
|
|
665
|
-
const activeCount = sessions.filter((s) =>
|
|
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
|
|
666
672
|
return activeCount + activeReservations.size
|
|
667
673
|
}
|
|
668
674
|
|
|
@@ -689,10 +695,12 @@ export function liveSessions({ store, agents }) {
|
|
|
689
695
|
if (live.origin === 'subagent' || live.parentId !== undefined || live.meta?.origin === 'subagent' || live.isSubagent) {
|
|
690
696
|
continue
|
|
691
697
|
}
|
|
698
|
+
const isWorkflowActive = task.workflowStatus === 'running' || (typeof task.workflowRunId === 'string' && task.workflowRunId !== '' && task.workflowStatus !== 'completed' && task.workflowStatus !== 'failed' && task.workflowStatus !== 'cancelled')
|
|
692
699
|
if (!byId.has(id)) byId.set(id, { sessionId: id, status: live.status, tasks: [] })
|
|
693
700
|
byId.get(id).tasks.push({
|
|
694
701
|
id: task.id, title: task.title, repo: task.repo, issueNumber: task.issueNumber,
|
|
695
702
|
queued: typeof task.queuedAt === 'number' && task.queuedAt > 0,
|
|
703
|
+
workflowActive: isWorkflowActive,
|
|
696
704
|
})
|
|
697
705
|
}
|
|
698
706
|
return [...byId.values()]
|
|
@@ -715,34 +723,36 @@ export async function dispatchNextQueuedTask({
|
|
|
715
723
|
const queued = typeof store.listQueuedTasks === 'function' ? store.listQueuedTasks() : []
|
|
716
724
|
if (queued.length === 0) return null
|
|
717
725
|
|
|
718
|
-
const nextTask
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
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
|
+
}
|
|
747
756
|
}
|
|
757
|
+
return null
|
|
748
758
|
}
|
package/lib/routes.js
CHANGED
|
@@ -412,7 +412,13 @@ export function importBoard({ store, input }) {
|
|
|
412
412
|
const doImport = () => {
|
|
413
413
|
for (const board of Array.isArray(input.boards) ? input.boards : []) {
|
|
414
414
|
if (typeof board?.id !== 'string' || board.id === '') continue
|
|
415
|
-
store.createBoard({
|
|
415
|
+
store.createBoard({
|
|
416
|
+
id: board.id,
|
|
417
|
+
title: board.title,
|
|
418
|
+
kind: board.kind,
|
|
419
|
+
position: board.position,
|
|
420
|
+
createdAt: board.createdAt,
|
|
421
|
+
})
|
|
416
422
|
}
|
|
417
423
|
|
|
418
424
|
for (const task of input.tasks) {
|
package/lib/store.js
CHANGED
|
@@ -94,8 +94,9 @@ CREATE TABLE IF NOT EXISTS dismissed (
|
|
|
94
94
|
owner TEXT NOT NULL,
|
|
95
95
|
repo TEXT NOT NULL,
|
|
96
96
|
issueNumber INTEGER NOT NULL,
|
|
97
|
+
forge TEXT NOT NULL DEFAULT '',
|
|
97
98
|
at INTEGER NOT NULL,
|
|
98
|
-
PRIMARY KEY (owner, repo, issueNumber)
|
|
99
|
+
PRIMARY KEY (owner, repo, issueNumber, forge)
|
|
99
100
|
);
|
|
100
101
|
`
|
|
101
102
|
|
|
@@ -301,6 +302,21 @@ export function openStore({ dir }) {
|
|
|
301
302
|
try { db.exec('ROLLBACK') } catch { /* ignore */ }
|
|
302
303
|
}
|
|
303
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
|
+
}
|
|
304
320
|
db.exec('PRAGMA synchronous = NORMAL')
|
|
305
321
|
try { db.exec('PRAGMA optimize') } catch { /* pragma optimize best-effort */ }
|
|
306
322
|
|
|
@@ -357,7 +373,7 @@ export function openStore({ dir }) {
|
|
|
357
373
|
archived: db.prepare('SELECT * FROM tasks WHERE archivedAt > 0 ORDER BY archivedAt DESC'),
|
|
358
374
|
archivable: db.prepare(
|
|
359
375
|
"SELECT * FROM tasks WHERE archivedAt = 0 AND col = 'done' AND columnAt > 0 AND columnAt <= ?"),
|
|
360
|
-
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 (?, ?, ?, ?, ?)'),
|
|
361
377
|
isDismissed: db.prepare('SELECT 1 FROM dismissed WHERE owner = ? AND repo = ? AND issueNumber = ?'),
|
|
362
378
|
isDismissedWithForge: db.prepare(`SELECT 1 FROM dismissed WHERE owner = ? AND repo = ? AND issueNumber = ? AND (forge = ? OR (forge = '' AND ? = 'gitea'))`),
|
|
363
379
|
byIssue: db.prepare('SELECT * FROM tasks WHERE owner = ? AND repo = ? AND issueNumber = ?'),
|
|
@@ -378,12 +394,19 @@ export function openStore({ dir }) {
|
|
|
378
394
|
"SELECT COUNT(*) AS total, SUM(CASE WHEN col = 'done' THEN 1 ELSE 0 END) AS completed FROM tasks WHERE parentId = ? AND archivedAt = 0"),
|
|
379
395
|
}
|
|
380
396
|
|
|
381
|
-
function ensureBoard(id, title, kind) {
|
|
397
|
+
function ensureBoard(id, title, kind, position, createdAt) {
|
|
382
398
|
const found = q.boardById.get(id)
|
|
383
|
-
if (found !== undefined)
|
|
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
|
+
}
|
|
384
406
|
const last = q.lastBoard.get()
|
|
385
|
-
const
|
|
386
|
-
|
|
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)
|
|
387
410
|
return q.boardById.get(id)
|
|
388
411
|
}
|
|
389
412
|
|
|
@@ -453,8 +476,8 @@ export function openStore({ dir }) {
|
|
|
453
476
|
return q.boards.all()
|
|
454
477
|
},
|
|
455
478
|
|
|
456
|
-
createBoard({ id, title, kind }) {
|
|
457
|
-
return ensureBoard(id, title ?? id, kind)
|
|
479
|
+
createBoard({ id, title, kind, position, createdAt }) {
|
|
480
|
+
return ensureBoard(id, title ?? id, kind, position, createdAt)
|
|
458
481
|
},
|
|
459
482
|
|
|
460
483
|
/** Board kind. Unrecognized kinds default to 'project'. */
|
|
@@ -646,7 +669,9 @@ export function openStore({ dir }) {
|
|
|
646
669
|
const checklist = JSON.stringify(Array.isArray(task.checklist) ? task.checklist : [])
|
|
647
670
|
const report = JSON.stringify(task.report && typeof task.report === 'object' ? task.report : {})
|
|
648
671
|
const comments = JSON.stringify(Array.isArray(task.comments) ? task.comments : [])
|
|
649
|
-
const forge =
|
|
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' : ''))
|
|
650
675
|
const targetBoard = task.board || 'main'
|
|
651
676
|
const targetCol = task.column || task.col || 'backlog'
|
|
652
677
|
const position = isValidOrderKey(task.position) ? task.position : positionFor(targetBoard, targetCol, undefined, undefined)
|
|
@@ -774,10 +799,10 @@ export function openStore({ dir }) {
|
|
|
774
799
|
},
|
|
775
800
|
|
|
776
801
|
deleteTask(id) {
|
|
777
|
-
// Record deleted
|
|
802
|
+
// Record deleted tasks as dismissed to prevent the intake loop from re-importing them.
|
|
778
803
|
const task = q.taskById.get(id)
|
|
779
804
|
if (task?.owner && task?.repo && typeof task.issueNumber === 'number') {
|
|
780
|
-
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())
|
|
781
806
|
}
|
|
782
807
|
q.deleteTask.run(id)
|
|
783
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
|
}
|
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) {
|
|
@@ -137,13 +137,13 @@ export async function isGitRepo(repoDir, gitRunner = defaultGitRunner) {
|
|
|
137
137
|
const res = await gitRunner(['rev-parse', '--is-inside-work-tree'], repoDir)
|
|
138
138
|
return res.stdout?.trim() === 'true'
|
|
139
139
|
} catch (err) {
|
|
140
|
-
const msg = String(err?.stderr || err?.message || '')
|
|
140
|
+
const msg = String(err?.stderr || err?.message || '').toLowerCase()
|
|
141
141
|
if (
|
|
142
|
-
err?.exitCode === 128 ||
|
|
143
|
-
err?.code === 128 ||
|
|
144
142
|
msg.includes('not a git repo') ||
|
|
145
143
|
msg.includes('not a git repository') ||
|
|
146
|
-
|
|
144
|
+
msg.includes('не найден git репозиторий') ||
|
|
145
|
+
msg.includes('не является git-репозиторием') ||
|
|
146
|
+
(err?.code === 'ENOENT' && repoDir && !existsSync(repoDir))
|
|
147
147
|
) {
|
|
148
148
|
return false
|
|
149
149
|
}
|
|
@@ -394,6 +394,45 @@ export async function cleanupTaskWorktree({
|
|
|
394
394
|
return { error: 'unfenced-worktree-path', status: 403 }
|
|
395
395
|
}
|
|
396
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
|
+
|
|
397
436
|
const targetRepoDir = repoDir || current.worktree
|
|
398
437
|
const result = await removeTaskWorktree({
|
|
399
438
|
repoDir: targetRepoDir,
|
|
@@ -567,6 +606,15 @@ export async function mergeTaskWorktree({
|
|
|
567
606
|
return { success: false, merged: false, error: 'main-repo-not-found' }
|
|
568
607
|
}
|
|
569
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
|
+
|
|
570
618
|
const registered = await listWorktrees(mainRepoDir, gitRunner)
|
|
571
619
|
const normTaskWorktree = resolve(task.worktree)
|
|
572
620
|
if (!registered.some((p) => resolve(p) === normTaskWorktree)) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-kanban",
|
|
3
|
-
"version": "0.2.
|
|
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
|
+
}
|