@goodandready/dsh-kanban 0.2.1 → 0.2.7
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/README.md +10 -0
- package/{docs/README.ru.md → README.ru.md} +10 -0
- package/{docs/README.zh.md → README.zh.md} +10 -0
- package/lib/board-tool.js +151 -0
- package/lib/client.js +862 -739
- package/lib/gitea.js +15 -0
- package/lib/index.js +112 -93
- package/lib/routes.js +357 -1
- package/lib/store.js +42 -4
- package/lib/sync.js +24 -1
- package/package.json +3 -2
- package/docs/design/DESIGN.md +0 -93
- package/docs/plans/2026-08-26-dsh-kanban-0.1.md +0 -169
- package/docs/plans/2026-09-07-quality-acceptance-gateway.md +0 -25
- package/docs/plans/2026-09-08-concurrency-safety.md +0 -8
- package/docs/plans/2026-09-08-diff-viewer-merge.md +0 -7
- package/docs/plans/2026-09-08-session-worktree-stability.md +0 -33
package/lib/routes.js
CHANGED
|
@@ -115,7 +115,13 @@ export function buildBoard({ store, config, board = 'main', repo, liveOf, sync,
|
|
|
115
115
|
bySession.get(id).push(task)
|
|
116
116
|
}
|
|
117
117
|
const tasks = orderForBoard(
|
|
118
|
-
raw.map((task) =>
|
|
118
|
+
raw.map((task) => {
|
|
119
|
+
const enriched = withPlan(task, liveOf, config, now, bySession.get(task.sessionId) ?? [])
|
|
120
|
+
if (task.isEpic) {
|
|
121
|
+
enriched.subtasksCount = store.countSubtasks(task.id)
|
|
122
|
+
}
|
|
123
|
+
return enriched
|
|
124
|
+
}),
|
|
119
125
|
columnsOf(store.boardKind(board)),
|
|
120
126
|
)
|
|
121
127
|
const kind = store.boardKind(board)
|
|
@@ -495,3 +501,353 @@ export function exportTasksCsv({ store, board = 'main' }) {
|
|
|
495
501
|
// Префикс UTF-8 BOM для Microsoft Excel
|
|
496
502
|
return '\uFEFF' + rows.join('\r\n')
|
|
497
503
|
}
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Очистить воркдерево задачи.
|
|
508
|
+
*/
|
|
509
|
+
export async function handleCleanupWorktree({ store, task, workspaceRegistry, force, cleanupTaskWorktree }) {
|
|
510
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
511
|
+
const out = await cleanupTaskWorktree({
|
|
512
|
+
store,
|
|
513
|
+
task,
|
|
514
|
+
workspaceRegistry,
|
|
515
|
+
force: force === true,
|
|
516
|
+
})
|
|
517
|
+
return { status: 200, body: out }
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Получить diff рабочей копии задачи.
|
|
522
|
+
*/
|
|
523
|
+
export async function handleTaskDiff({ task, getTaskDiff }) {
|
|
524
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
525
|
+
const diffResult = await getTaskDiff({ worktreePath: task.worktree })
|
|
526
|
+
return { status: 200, body: diffResult }
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Получить список коммитов задачи.
|
|
531
|
+
*/
|
|
532
|
+
export async function handleTaskCommits({ task, getTaskCommits }) {
|
|
533
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
534
|
+
const commits = await getTaskCommits({ worktreePath: task.worktree })
|
|
535
|
+
return { status: 200, body: { commits } }
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Слить воркдерево задачи и перевести в done.
|
|
540
|
+
*/
|
|
541
|
+
export async function handleTaskMerge({ store, task, targetBranch, workspaceRegistry, mergeTaskWorktree }) {
|
|
542
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
543
|
+
const mergeResult = await mergeTaskWorktree({
|
|
544
|
+
task,
|
|
545
|
+
targetBranch: typeof targetBranch === 'string' ? targetBranch.trim() : undefined,
|
|
546
|
+
workspaceRegistry,
|
|
547
|
+
})
|
|
548
|
+
if (!mergeResult.success) {
|
|
549
|
+
return {
|
|
550
|
+
status: 400,
|
|
551
|
+
body: { error: mergeResult.error, detail: mergeResult.detail, files: mergeResult.files },
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
const updated = store.acceptTask(task.id)
|
|
555
|
+
store.addTransition({
|
|
556
|
+
taskId: task.id,
|
|
557
|
+
fromCol: task.column,
|
|
558
|
+
toCol: 'done',
|
|
559
|
+
source: 'manual',
|
|
560
|
+
detail: `принята и смержена ветка ${task.branch} в ${mergeResult.targetBranch}`,
|
|
561
|
+
})
|
|
562
|
+
return { status: 200, body: { task: updated, merged: true, targetBranch: mergeResult.targetBranch } }
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Проверить статус воркдерева задачи (ветка, dirty, воркспейс).
|
|
567
|
+
*/
|
|
568
|
+
export async function handleTaskWorktreeStatus({ task, checkWorktreeDirty }) {
|
|
569
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
570
|
+
const dirtyCheck = await checkWorktreeDirty({ worktreePath: task.worktree })
|
|
571
|
+
return {
|
|
572
|
+
status: 200,
|
|
573
|
+
body: {
|
|
574
|
+
worktree: task.worktree || '',
|
|
575
|
+
branch: task.branch || '',
|
|
576
|
+
workspaceId: task.workspaceId || '',
|
|
577
|
+
dirty: dirtyCheck.dirty,
|
|
578
|
+
files: dirtyCheck.files,
|
|
579
|
+
},
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Обновить DoD чеклист задачи.
|
|
585
|
+
*/
|
|
586
|
+
export function handleTaskChecklist({ store, task, input }) {
|
|
587
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
588
|
+
const list = Array.isArray(input?.checklist) ? input.checklist : Array.isArray(input) ? input : []
|
|
589
|
+
const updated = store.updateChecklist(task.id, list)
|
|
590
|
+
return { status: 200, body: { task: updated } }
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Установить отчёт выполнения задачи.
|
|
595
|
+
*/
|
|
596
|
+
export function handleTaskReport({ store, task, input }) {
|
|
597
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
598
|
+
const updated = store.setReport(task.id, input)
|
|
599
|
+
return { status: 200, body: { task: updated } }
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Получить комментарии задачи.
|
|
604
|
+
*/
|
|
605
|
+
export function handleGetTaskComments({ task }) {
|
|
606
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
607
|
+
return { status: 200, body: { comments: task.comments || [] } }
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Добавить комментарий к задаче.
|
|
612
|
+
*/
|
|
613
|
+
export function handleAddTaskComment({ store, task, input }) {
|
|
614
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
615
|
+
const author = input?.author || 'user'
|
|
616
|
+
const role = input?.role || 'user'
|
|
617
|
+
const text = input?.text || ''
|
|
618
|
+
const updated = store.addComment(task.id, { author, role, text })
|
|
619
|
+
return { status: 200, body: { task: updated } }
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Принять задачу на ревью (перевод в done).
|
|
624
|
+
*/
|
|
625
|
+
export function handleAcceptTask({ store, task }) {
|
|
626
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
627
|
+
const updated = store.acceptTask(task.id)
|
|
628
|
+
return { status: 200, body: { task: updated } }
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Отклонить задачу на ревью (перевод обратно в in_progress).
|
|
633
|
+
*/
|
|
634
|
+
export function handleRejectTask({ store, task, input }) {
|
|
635
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
636
|
+
const reason = String(input?.reason || '').trim()
|
|
637
|
+
if (!reason) return { status: 400, body: { error: 'reason-required' } }
|
|
638
|
+
const updated = store.rejectTask(task.id, reason)
|
|
639
|
+
return { status: 200, body: { task: updated } }
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Сохранить дамп прогресса задачи.
|
|
644
|
+
*/
|
|
645
|
+
export function handleProgressDump({ store, task, input, formatProgressDump }) {
|
|
646
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
647
|
+
const formatted = typeof input === 'string' ? input : formatProgressDump(input)
|
|
648
|
+
const updated = store.updateTask(task.id, { progressDump: formatted })
|
|
649
|
+
return { status: 200, body: { task: updated } }
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Decompose a task into subtasks and make the parent an Epic.
|
|
654
|
+
*/
|
|
655
|
+
export async function handleTriageTask({ store, gitea, task, input }) {
|
|
656
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
657
|
+
const subtasksInput = Array.isArray(input?.subtasks) ? input.subtasks : []
|
|
658
|
+
if (subtasksInput.length === 0) {
|
|
659
|
+
return { status: 400, body: { error: 'subtasks-required' } }
|
|
660
|
+
}
|
|
661
|
+
const createGitea = input?.createGiteaIssues === true && Boolean(task.owner && task.repo && gitea)
|
|
662
|
+
|
|
663
|
+
// Convert parent into Epic
|
|
664
|
+
store.updateTask(task.id, { isEpic: true })
|
|
665
|
+
|
|
666
|
+
const created = []
|
|
667
|
+
for (const item of subtasksInput) {
|
|
668
|
+
const title = String(item?.title ?? '').trim()
|
|
669
|
+
if (!title) continue
|
|
670
|
+
const body = String(item?.body ?? '').trim()
|
|
671
|
+
let issueNumber = null
|
|
672
|
+
let issueUrl = null
|
|
673
|
+
|
|
674
|
+
if (createGitea) {
|
|
675
|
+
try {
|
|
676
|
+
const issueBody = body ? `${body}\n\nParent epic: #${task.issueNumber || task.id}` : `Parent epic: #${task.issueNumber || task.id}`
|
|
677
|
+
const giteaIssue = await gitea.createIssue({
|
|
678
|
+
owner: task.owner,
|
|
679
|
+
repo: task.repo,
|
|
680
|
+
title,
|
|
681
|
+
body: issueBody,
|
|
682
|
+
})
|
|
683
|
+
if (giteaIssue?.number) {
|
|
684
|
+
issueNumber = giteaIssue.number
|
|
685
|
+
issueUrl = giteaIssue.html_url || null
|
|
686
|
+
}
|
|
687
|
+
} catch {}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
const sub = store.createTask({
|
|
691
|
+
board: task.board || 'main',
|
|
692
|
+
column: 'backlog',
|
|
693
|
+
title,
|
|
694
|
+
body,
|
|
695
|
+
parentId: task.id,
|
|
696
|
+
owner: task.owner || null,
|
|
697
|
+
repo: task.repo || null,
|
|
698
|
+
issueNumber,
|
|
699
|
+
issueUrl,
|
|
700
|
+
labels: Array.isArray(item?.labels) ? item.labels : [],
|
|
701
|
+
workspaceId: task.workspaceId || '',
|
|
702
|
+
})
|
|
703
|
+
created.push(sub)
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
store.addTransition({
|
|
707
|
+
taskId: task.id,
|
|
708
|
+
fromCol: task.column,
|
|
709
|
+
toCol: task.column,
|
|
710
|
+
source: 'manual',
|
|
711
|
+
detail: `decomposed into ${created.length} subtasks (epic triage)`,
|
|
712
|
+
})
|
|
713
|
+
|
|
714
|
+
return {
|
|
715
|
+
status: 200,
|
|
716
|
+
body: {
|
|
717
|
+
epic: store.getTask(task.id),
|
|
718
|
+
subtasks: store.listSubtasks(task.id),
|
|
719
|
+
counts: store.countSubtasks(task.id),
|
|
720
|
+
createdCount: created.length,
|
|
721
|
+
},
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* Get subtasks of a task.
|
|
727
|
+
*/
|
|
728
|
+
export function handleGetSubtasks({ store, task }) {
|
|
729
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
730
|
+
return {
|
|
731
|
+
status: 200,
|
|
732
|
+
body: {
|
|
733
|
+
subtasks: store.listSubtasks(task.id),
|
|
734
|
+
counts: store.countSubtasks(task.id),
|
|
735
|
+
},
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* Create a single subtask linked to a parent task.
|
|
741
|
+
*/
|
|
742
|
+
export async function handleCreateSubtask({ store, gitea, task, input }) {
|
|
743
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
744
|
+
const title = String(input?.title ?? '').trim()
|
|
745
|
+
if (!title) return { status: 400, body: { error: 'title-required' } }
|
|
746
|
+
const body = String(input?.body ?? '').trim()
|
|
747
|
+
let issueNumber = null
|
|
748
|
+
let issueUrl = null
|
|
749
|
+
|
|
750
|
+
if (input?.createGiteaIssue === true && task.owner && task.repo && gitea) {
|
|
751
|
+
try {
|
|
752
|
+
const issueBody = body ? `${body}\n\nParent epic: #${task.issueNumber || task.id}` : `Parent epic: #${task.issueNumber || task.id}`
|
|
753
|
+
const giteaIssue = await gitea.createIssue({
|
|
754
|
+
owner: task.owner,
|
|
755
|
+
repo: task.repo,
|
|
756
|
+
title,
|
|
757
|
+
body: issueBody,
|
|
758
|
+
})
|
|
759
|
+
if (giteaIssue?.number) {
|
|
760
|
+
issueNumber = giteaIssue.number
|
|
761
|
+
issueUrl = giteaIssue.html_url || null
|
|
762
|
+
}
|
|
763
|
+
} catch {}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
store.updateTask(task.id, { isEpic: true })
|
|
767
|
+
const sub = store.createTask({
|
|
768
|
+
board: task.board || 'main',
|
|
769
|
+
column: input?.column || 'backlog',
|
|
770
|
+
title,
|
|
771
|
+
body,
|
|
772
|
+
parentId: task.id,
|
|
773
|
+
owner: task.owner || null,
|
|
774
|
+
repo: task.repo || null,
|
|
775
|
+
issueNumber,
|
|
776
|
+
issueUrl,
|
|
777
|
+
labels: Array.isArray(input?.labels) ? input.labels : [],
|
|
778
|
+
workspaceId: task.workspaceId || '',
|
|
779
|
+
})
|
|
780
|
+
return { status: 200, body: { subtask: sub, counts: store.countSubtasks(task.id) } }
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* Toggle a checklist item atomically by index or text.
|
|
785
|
+
*/
|
|
786
|
+
export function handleToggleChecklistItem({ store, task, input }) {
|
|
787
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
788
|
+
const current = Array.isArray(task.checklist) ? [...task.checklist] : []
|
|
789
|
+
let targetIndex = -1
|
|
790
|
+
|
|
791
|
+
if (typeof input?.index === 'number' && input.index >= 0 && input.index < current.length) {
|
|
792
|
+
targetIndex = input.index
|
|
793
|
+
} else if (typeof input?.text === 'string' && input.text.trim()) {
|
|
794
|
+
const q = input.text.trim().toLowerCase()
|
|
795
|
+
targetIndex = current.findIndex((item) => {
|
|
796
|
+
const t = typeof item === 'string' ? item : item?.text
|
|
797
|
+
return typeof t === 'string' && t.toLowerCase().includes(q)
|
|
798
|
+
})
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
if (targetIndex === -1) {
|
|
802
|
+
return { status: 404, body: { error: 'checklist-item-not-found' } }
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
const item = current[targetIndex]
|
|
806
|
+
let updatedItem
|
|
807
|
+
if (typeof item === 'string') {
|
|
808
|
+
const checked = input?.checked !== undefined ? Boolean(input.checked) : true
|
|
809
|
+
updatedItem = { text: item, done: checked }
|
|
810
|
+
} else {
|
|
811
|
+
const checked = input?.checked !== undefined ? Boolean(input.checked) : !item.done
|
|
812
|
+
updatedItem = { ...item, done: checked }
|
|
813
|
+
}
|
|
814
|
+
current[targetIndex] = updatedItem
|
|
815
|
+
const updated = store.updateChecklist(task.id, current)
|
|
816
|
+
return { status: 200, body: { task: updated, item: updatedItem, index: targetIndex } }
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/**
|
|
820
|
+
* Create a Gitea Pull Request for a task.
|
|
821
|
+
*/
|
|
822
|
+
export async function handleCreateTaskPr({ store, gitea, task, input }) {
|
|
823
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
824
|
+
if (!task.owner || !task.repo) {
|
|
825
|
+
return { status: 400, body: { error: 'task-no-repo' } }
|
|
826
|
+
}
|
|
827
|
+
const head = String(input?.head || task.branch || '').trim()
|
|
828
|
+
if (!head) return { status: 400, body: { error: 'branch-required' } }
|
|
829
|
+
const base = String(input?.base || 'main').trim()
|
|
830
|
+
const title = String(input?.title || task.title || `PR: ${task.title}`).trim()
|
|
831
|
+
const body = String(input?.body || (task.issueNumber ? `Closes #${task.issueNumber}` : '')).trim()
|
|
832
|
+
|
|
833
|
+
try {
|
|
834
|
+
const pr = await gitea.createPullRequest({
|
|
835
|
+
owner: task.owner,
|
|
836
|
+
repo: task.repo,
|
|
837
|
+
title,
|
|
838
|
+
body,
|
|
839
|
+
head,
|
|
840
|
+
base,
|
|
841
|
+
})
|
|
842
|
+
store.addTransition({
|
|
843
|
+
taskId: task.id,
|
|
844
|
+
fromCol: task.column,
|
|
845
|
+
toCol: task.column,
|
|
846
|
+
source: 'manual',
|
|
847
|
+
detail: `Created PR #${pr?.number || ''}: ${head} -> ${base}`,
|
|
848
|
+
})
|
|
849
|
+
return { status: 200, body: { pr, prUrl: pr?.html_url } }
|
|
850
|
+
} catch (err) {
|
|
851
|
+
return { status: 502, body: { error: 'gitea-pr-failed', detail: err?.message } }
|
|
852
|
+
}
|
|
853
|
+
}
|
package/lib/store.js
CHANGED
|
@@ -70,6 +70,8 @@ CREATE TABLE IF NOT EXISTS tasks (
|
|
|
70
70
|
comments TEXT NOT NULL DEFAULT '[]',
|
|
71
71
|
rejectReason TEXT NOT NULL DEFAULT '',
|
|
72
72
|
rejectedAt INTEGER NOT NULL DEFAULT 0,
|
|
73
|
+
parentId TEXT NOT NULL DEFAULT '',
|
|
74
|
+
isEpic INTEGER NOT NULL DEFAULT 0,
|
|
73
75
|
updatedAt INTEGER NOT NULL
|
|
74
76
|
);
|
|
75
77
|
CREATE INDEX IF NOT EXISTS tasks_board_col ON tasks(board, col, position);
|
|
@@ -98,7 +100,8 @@ const WRITABLE = [
|
|
|
98
100
|
'title', 'body', 'owner', 'repo', 'issueNumber', 'issueUrl', 'labels',
|
|
99
101
|
'branch', 'worktree', 'workspaceId', 'sessionId', 'model', 'provider', 'waiting', 'syncedAt', 'plan',
|
|
100
102
|
'labelColors', 'queuedAt', 'createdAt', 'author', 'assignee', 'milestone', 'priority', 'dueAt',
|
|
101
|
-
'cron', 'nextRunAt', 'lastRunAt', 'progressDump', 'permission', 'reasoningEffort', 'permissionConfirmedAt',
|
|
103
|
+
'cron', 'nextRunAt', 'lastRunAt', 'progressDump', 'permission', 'reasoningEffort', 'permissionConfirmedAt',
|
|
104
|
+
'runs', 'checklist', 'report', 'comments', 'rejectReason', 'rejectedAt', 'parentId', 'isEpic',
|
|
102
105
|
]
|
|
103
106
|
|
|
104
107
|
/** Источники перехода; чужое значение не пишем, чтобы журнал оставался читаемым. */
|
|
@@ -106,7 +109,7 @@ const SOURCES = new Set(['manual', 'session', 'gitea', 'tool'])
|
|
|
106
109
|
|
|
107
110
|
function rowToTask(row) {
|
|
108
111
|
if (row === undefined) return undefined
|
|
109
|
-
const { col, labels, waiting, labelColors, runs, checklist, report, comments, rejectReason, rejectedAt, ...rest } = row
|
|
112
|
+
const { col, labels, waiting, labelColors, runs, checklist, report, comments, rejectReason, rejectedAt, isEpic, parentId, ...rest } = row
|
|
110
113
|
let parsedChecklist = []
|
|
111
114
|
try {
|
|
112
115
|
const value = JSON.parse(checklist ?? '[]')
|
|
@@ -150,6 +153,8 @@ function rowToTask(row) {
|
|
|
150
153
|
rejectReason: rejectReason ?? '',
|
|
151
154
|
rejectedAt: Number(rejectedAt) || 0,
|
|
152
155
|
reasoningEffort: rest.reasoningEffort ?? '',
|
|
156
|
+
isEpic: isEpic === 1,
|
|
157
|
+
parentId: parentId ?? '',
|
|
153
158
|
}
|
|
154
159
|
}
|
|
155
160
|
|
|
@@ -194,6 +199,8 @@ export function openStore({ dir }) {
|
|
|
194
199
|
['comments', "TEXT NOT NULL DEFAULT '[]'"],
|
|
195
200
|
['rejectReason', "TEXT NOT NULL DEFAULT ''"],
|
|
196
201
|
['rejectedAt', "INTEGER NOT NULL DEFAULT 0"],
|
|
202
|
+
['parentId', "TEXT NOT NULL DEFAULT ''"],
|
|
203
|
+
['isEpic', "INTEGER NOT NULL DEFAULT 0"],
|
|
197
204
|
]) {
|
|
198
205
|
try { db.exec(`ALTER TABLE tasks ADD COLUMN ${column} ${definition}`) } catch { /* столбец уже есть */ }
|
|
199
206
|
}
|
|
@@ -202,6 +209,15 @@ export function openStore({ dir }) {
|
|
|
202
209
|
// заведения: это честнее нуля, который читался бы как «в 1970 году».
|
|
203
210
|
db.exec('UPDATE tasks SET columnAt = createdAt WHERE columnAt = 0')
|
|
204
211
|
|
|
212
|
+
// Индексы для ускорения выборок активных задач, поиска по Gitea issue и cron-задач
|
|
213
|
+
db.exec('CREATE INDEX IF NOT EXISTS tasks_board_active ON tasks(board, archivedAt, col, position)')
|
|
214
|
+
db.exec('CREATE INDEX IF NOT EXISTS tasks_gitea_issue ON tasks(owner, repo, issueNumber)')
|
|
215
|
+
db.exec('CREATE INDEX IF NOT EXISTS tasks_cron_next ON tasks(archivedAt, nextRunAt)')
|
|
216
|
+
db.exec('CREATE INDEX IF NOT EXISTS tasks_queued ON tasks(archivedAt, queuedAt)')
|
|
217
|
+
db.exec('CREATE INDEX IF NOT EXISTS tasks_parent_id ON tasks(parentId)')
|
|
218
|
+
db.exec('PRAGMA synchronous = NORMAL')
|
|
219
|
+
try { db.exec('PRAGMA optimize') } catch {}
|
|
220
|
+
|
|
205
221
|
const q = {
|
|
206
222
|
boards: db.prepare('SELECT * FROM boards ORDER BY position'),
|
|
207
223
|
boardById: db.prepare('SELECT * FROM boards WHERE id = ?'),
|
|
@@ -227,6 +243,9 @@ export function openStore({ dir }) {
|
|
|
227
243
|
"SELECT * FROM tasks WHERE issueNumber IS NOT NULL AND col NOT IN ('done') ORDER BY updatedAt DESC"),
|
|
228
244
|
scheduledDue: db.prepare(
|
|
229
245
|
"SELECT * FROM tasks WHERE cron != '' AND nextRunAt > 0 AND nextRunAt <= ? AND archivedAt = 0 ORDER BY nextRunAt ASC"),
|
|
246
|
+
subtasksOf: db.prepare('SELECT * FROM tasks WHERE parentId = ? AND archivedAt = 0 ORDER BY createdAt ASC, position ASC'),
|
|
247
|
+
countSubtasks: db.prepare(
|
|
248
|
+
"SELECT COUNT(*) AS total, SUM(CASE WHEN col = 'done' THEN 1 ELSE 0 END) AS completed FROM tasks WHERE parentId = ? AND archivedAt = 0"),
|
|
230
249
|
}
|
|
231
250
|
|
|
232
251
|
function ensureBoard(id, title, kind) {
|
|
@@ -358,8 +377,8 @@ export function openStore({ dir }) {
|
|
|
358
377
|
labels, branch, worktree, workspaceId, sessionId, model, provider, waiting, syncedAt,
|
|
359
378
|
labelColors, author, assignee, milestone, priority, dueAt, columnAt, createdAt, updatedAt,
|
|
360
379
|
cron, nextRunAt, lastRunAt, progressDump, permission, permissionConfirmedAt, runs,
|
|
361
|
-
checklist, report, comments, rejectReason, rejectedAt, reasoningEffort)
|
|
362
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
|
|
380
|
+
checklist, report, comments, rejectReason, rejectedAt, reasoningEffort, parentId, isEpic)
|
|
381
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
|
|
363
382
|
id, board, column, position,
|
|
364
383
|
input.title ?? '', input.body ?? '',
|
|
365
384
|
input.owner ?? null, input.repo ?? null,
|
|
@@ -389,6 +408,8 @@ export function openStore({ dir }) {
|
|
|
389
408
|
input.rejectReason ?? '',
|
|
390
409
|
Number(input.rejectedAt) || 0,
|
|
391
410
|
typeof input.reasoningEffort === 'string' ? input.reasoningEffort : '',
|
|
411
|
+
input.parentId ?? '',
|
|
412
|
+
input.isEpic ? 1 : 0,
|
|
392
413
|
)
|
|
393
414
|
return rowToTask(q.taskById.get(id))
|
|
394
415
|
},
|
|
@@ -411,6 +432,8 @@ export function openStore({ dir }) {
|
|
|
411
432
|
else if (key === 'rejectReason') values.push(String(patch[key] ?? ''))
|
|
412
433
|
else if (key === 'waiting') values.push(patch[key] ? 1 : 0)
|
|
413
434
|
else if (key === 'syncedAt') values.push(Number(patch[key]) || 0)
|
|
435
|
+
else if (key === 'isEpic') values.push(patch[key] ? 1 : 0)
|
|
436
|
+
else if (key === 'parentId') values.push(String(patch[key] ?? ''))
|
|
414
437
|
else values.push(patch[key] ?? null)
|
|
415
438
|
}
|
|
416
439
|
if (fields.length > 0) {
|
|
@@ -546,6 +569,21 @@ export function openStore({ dir }) {
|
|
|
546
569
|
return rowToTask(q.byIssue.get(owner, repo, issueNumber))
|
|
547
570
|
},
|
|
548
571
|
|
|
572
|
+
listSubtasks(parentId) {
|
|
573
|
+
if (!parentId) return []
|
|
574
|
+
return q.subtasksOf.all(parentId).map(rowToTask)
|
|
575
|
+
},
|
|
576
|
+
|
|
577
|
+
countSubtasks(parentId) {
|
|
578
|
+
if (!parentId) return { total: 0, completed: 0 }
|
|
579
|
+
const res = q.countSubtasks.get(parentId)
|
|
580
|
+
return { total: res?.total ?? 0, completed: res?.completed ?? 0 }
|
|
581
|
+
},
|
|
582
|
+
|
|
583
|
+
setParent(id, parentId) {
|
|
584
|
+
return this.updateTask(id, { parentId: parentId ?? '' })
|
|
585
|
+
},
|
|
586
|
+
|
|
549
587
|
/** Задачи в архиве: свой список, а не колонка. */
|
|
550
588
|
listArchived() {
|
|
551
589
|
return q.archived.all().map(rowToTask)
|
package/lib/sync.js
CHANGED
|
@@ -9,6 +9,29 @@ import { watchedRepos, shouldTake, archiveBefore, isWatched } from './intake.js'
|
|
|
9
9
|
import { colorsOfIssue } from './filters.js'
|
|
10
10
|
import { issueToTask, issueBornAt, issueAuthor, issueAssignee, issueMilestone } from './import.js'
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Выполнить операцию с экспоненциальной задержкой (exponential backoff) при сбоях сети.
|
|
14
|
+
* @template T
|
|
15
|
+
* @param {() => Promise<T>} fn асинхронная операция
|
|
16
|
+
* @param {{retries?: number, baseMs?: number, maxMs?: number}} [opts]
|
|
17
|
+
* @returns {Promise<T>}
|
|
18
|
+
*/
|
|
19
|
+
export async function withBackoff(fn, { retries = 2, baseMs = 50, maxMs = 500 } = {}) {
|
|
20
|
+
let attempt = 0
|
|
21
|
+
let firstErr
|
|
22
|
+
while (true) {
|
|
23
|
+
try {
|
|
24
|
+
return await fn()
|
|
25
|
+
} catch (err) {
|
|
26
|
+
if (!firstErr) firstErr = err
|
|
27
|
+
if (attempt >= retries) throw (firstErr || err)
|
|
28
|
+
const delay = Math.min(baseMs * (2 ** attempt), maxMs)
|
|
29
|
+
attempt += 1
|
|
30
|
+
await new Promise((resolve) => setTimeout(resolve, delay))
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
12
35
|
/**
|
|
13
36
|
* Наблюдения по одному репозиторию.
|
|
14
37
|
*
|
|
@@ -202,7 +225,7 @@ export async function syncAll({ gitea, store, logger, only, now = Date.now() })
|
|
|
202
225
|
for (const group of byRepo.values()) {
|
|
203
226
|
let repoState
|
|
204
227
|
try {
|
|
205
|
-
repoState = await readRepo({ gitea, owner: group.owner, repo: group.repo })
|
|
228
|
+
repoState = await withBackoff(() => readRepo({ gitea, owner: group.owner, repo: group.repo }), { retries: 2, baseMs: 40 })
|
|
206
229
|
} catch (error) {
|
|
207
230
|
failed += group.tasks.length
|
|
208
231
|
reposFailed += 1
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-kanban",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.7",
|
|
4
4
|
"description": "Visual Kanban board for DeepSeek Harness: Gitea-backed tasks, workflow columns, and a dedicated agent session per task.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
"lib/",
|
|
15
15
|
"cordis.patch.yml",
|
|
16
16
|
"README.md",
|
|
17
|
-
"
|
|
17
|
+
"README.zh.md",
|
|
18
|
+
"README.ru.md",
|
|
18
19
|
"LICENSE"
|
|
19
20
|
],
|
|
20
21
|
"scripts": {
|
package/docs/design/DESIGN.md
DELETED
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
# DESIGN.md — dsh-kanban
|
|
2
|
-
|
|
3
|
-
## Product / Purpose
|
|
4
|
-
- Назначение: Интегрированная доска задач для DeepSeek Harness с двусторонней сверкой с Gitea, автоматическим подхватом сессий агентов и управлением жизненным циклом.
|
|
5
|
-
- Аудитория: Разработчики и операторы DeepSeek Harness, ведущие проектные задачи и сессии агентов.
|
|
6
|
-
- Статус: Production, версия 0.1.23+.
|
|
7
|
-
|
|
8
|
-
## User Surfaces
|
|
9
|
-
- Web/UI: Экран доски в центральной колонке интерфейса DSH (mountBoard), выдвигающаяся панель задач, фильтры, группировки (по проектам, по исполнителю), метрики переходов.
|
|
10
|
-
- DSH UI / settings / slots:
|
|
11
|
-
- Карточка настроек в слоте settings.plugin.item (пространство dsh-kanban).
|
|
12
|
-
- Чип активной задачи в conversation.session.header.utilities.
|
|
13
|
-
- Пункт бокового меню для вызова экрана доски.
|
|
14
|
-
- API: HTTP эндпоинты /dsh-kanban/* (board, snapshot, task, sync, webhook, metrics, whoami, checklist, report, comments, accept, reject).
|
|
15
|
-
- CLI: node tools/compat.mjs (проверка совместимости экспортов ядра).
|
|
16
|
-
- Документация: README.md, docs/README.ru.md, docs/README.zh.md, index.md.
|
|
17
|
-
|
|
18
|
-
## Visual Direction
|
|
19
|
-
- Атмосфера: Строгий утилитарный инструментальный интерфейс в едином стиле темы DeepSeek Harness.
|
|
20
|
-
- Утверждённые референсы и что из них берём: Системные карточки DSH (стили заголовков, отступы, шрифты, цветовые токены --dsw-alias-*).
|
|
21
|
-
- Не копировать: Внешние неинтегрированные темы, разнородные цвета рамок и теней.
|
|
22
|
-
|
|
23
|
-
## Foundations
|
|
24
|
-
- Цвета и роли: Использование CSS-переменных DSH темы (--dsw-alias-bg-layer-3, --dsw-alias-border-l2, --dsw-alias-label-primary, --dsw-alias-label-secondary).
|
|
25
|
-
- Типографика: Системный стек шрифтов DSH (inherit, 13-14px базовый размер, 11-12px для бейджей и служебных меток).
|
|
26
|
-
- Сетка, отступы, responsive: 5 стандартных колонок (backlog, ready, progress, review, done) с горизонтальным скроллом и адаптивными карточками.
|
|
27
|
-
- Accessibility: Нативные кнопки button, role="status" для сообщений, семантичные поля ввода с aria-label/label.
|
|
28
|
-
|
|
29
|
-
## Components And States
|
|
30
|
-
- Компоненты: BoardScreen, Column, Card, TaskDrawer (включая секции DoD Checklist, Execution Report, Comments Thread, Review Acceptance Gate), KanbanSettingsCard, TaskChip, MetricsModal.
|
|
31
|
-
- Loading / empty / error / success:
|
|
32
|
-
- Загрузка: состояние скелетона/лоадера без блокировки скролла.
|
|
33
|
-
- Пустые колонки: пунктирная область перетаскивания.
|
|
34
|
-
- Ошибки: всплывающий баннер с ключом локализации t('error.' + err).
|
|
35
|
-
- Формы, валидация и действия: Валидация обязательных полей при создании задачи, блокировка повторных кликов при сохранении.
|
|
36
|
-
|
|
37
|
-
## User Flows
|
|
38
|
-
- Критические сценарии:
|
|
39
|
-
1. Создание задачи / подхват из Gitea Issue.
|
|
40
|
-
2. Перемещение карточки между колонками человеком или через инструмент агента board_move.
|
|
41
|
-
3. Запуск сессии агента по карточке с автоматическим назначением ответственного.
|
|
42
|
-
4. Автоматическое обновление статуса (waiting) по событиям ядра (session/event).
|
|
43
|
-
5. Экспорт и импорт снимка доски в JSON.
|
|
44
|
-
|
|
45
|
-
## Do / Don't
|
|
46
|
-
- Do: Использовать только токены темы --dsw-alias-*, очищать все слушатели событий при размонтировании, соблюдать порядок хуков React.
|
|
47
|
-
- Don't: Не переводить карточку в done агентом (колонка done фиксируется только по факту закрытия issue и ветки).
|
|
48
|
-
|
|
49
|
-
## Locked Design Decisions
|
|
50
|
-
- 2026-08-20 — Карточка настроек монтируется в settings.plugin.item с резервом в settings.section.
|
|
51
|
-
- 2026-08-25 — Использование дробной индексации ($O(1)$) для позиционирования карточек.
|
|
52
|
-
- 2026-09-02 — Снимок доски объединён на маршруте /dsh-kanban/snapshot (GET: выгрузка, POST: загрузка).
|
|
53
|
-
- 2026-09-03 — Изоляция сессий задач в git worktree ($DSH_HOME/worktrees/<repo-key>/<task-id>/ на ветке task/<id>-<slug>), регистрация воркдерева в workspaceRegistry, проверка dirtyFiles и безопасная очистка в фазе cleanup (#193).
|
|
54
|
-
- 2026-09-03 — Серверная инфраструктура автономности и безопасности (#195): Host-Side 5-field Cron, PowerInhibitor (кроссплатформенная блокировка сна системы во время работы задач), PROGRESSDUMP (фиксация срезов контекста, маскирование секретов и передача эстафеты), Permission Confirmation Gate (ручное одобрение прав выше базовых), инъекция секции доски в systemPrompt и хранение истории запусков прямо в карточке задачи (tasks.runs).
|
|
55
|
-
|
|
56
|
-
- 2026-09-07 — Контур качества и приёмка человеком (#205–#209):
|
|
57
|
-
- Протокольный шлюз: колонка done является исключительной границей приёмки человеком; инструменту board_move запрещён прямой перевод задачи в done.
|
|
58
|
-
- DoD Acceptance Checklist: перевод в review блокируется до закрытия обязательных пунктов чеклиста с фиксацией доказательств (evidence).
|
|
59
|
-
- Structured Report: фиксация отчёта выполнения (summary, changedFiles, checksRun, artifacts, risks) в карточке задачи.
|
|
60
|
-
- Двусторонний цикл приёмки: явные действия человека Accept (в done) и Reject (возврат в in-progress с обязательной причиной rejectReason, инжектируемой в следующую сессию агента).
|
|
61
|
-
- Ветка комментариев к задаче с поддержкой ролей user и agent для сквозного диалога прямо в Task Drawer.
|
|
62
|
-
|
|
63
|
-
- 2026-09-08 — Стабильность сессий и воркдеревьев (#227, #226, #213, #215):
|
|
64
|
-
- Привязка проекта сессии: при создании агента с воркдеревом поле meta.cwd сохраняет канонический projectRoot (repoDir), предотвращая выпадение сессий в категорию «Ungrouped» в сайдбаре DSH; путь воркдерева передается в meta.worktreePath и инструкциях агента (#227).
|
|
65
|
-
- Режим «↻ Продолжить» (Resume Execution): эндпоинт POST /dsh-kanban/tasks/:id/resume и кнопка в TaskDrawer; сохраняет существующее воркдерево, ветку и незакоммиченные файлы, передавая агенту преамбулу продолжения и актуальный PROGRESSDUMP (#226).
|
|
66
|
-
- Фильтрация structural noise в проверках dirtyFiles: в checkWorktreeDirty добавлен флаг --ignore-submodules=all, исключающий ложные блокировки удаления воркдеревьев из-за субмодулей и дрейфа gitlinks (#213).
|
|
67
|
-
- Фильтрация сессий субагентов: сессии с маркером subagent (origin === 'subagent' или parentId !== undefined) исключаются из liveSessions и не перезаписывают состояние корневых задач (#215).
|
|
68
|
-
## Locked Decisions
|
|
69
|
-
- **0.2.1**: Карточка настроек плагина перенесена строго в слот `settings.plugin.item` (раздел «Настройки → Плагины → Настройки плагинов») без одновременной дублирующей регистрации в `settings.section` (Issue #236).
|
|
70
|
-
|
|
71
|
-
### 2026-09-08: Релиз v0.2.0 — Milestone (SSE, Chat Sync, Templates, Two-Column Drawer, Multi-Repo) (#210, #219, #220, #222, #225)
|
|
72
|
-
- **Live SSE Channel (`/dsh-kanban/events`)**: Постоянное SSE-соединение с автоматическим переподключением для мгновенного пуша обновлений доски (`task:change`, `board:change`) без избыточного поллинга (#222).
|
|
73
|
-
- **Auto-sync Sessions (`autoSyncSessions`)**: Опциональная автоматическая синхронизация стандартных диалогов чата DSH в карточки доски канбана с надежной фильтрацией внутренних сессий и субагентов (#225).
|
|
74
|
-
- **Task Templates Library (`/dsh-kanban/templates`)**: Библиотека из 5 встроенных типовых шаблонов (Feature, Bugfix, Refactor, Security, Release) с предзаполненными чеклистами DoD и шаблонами промптов (#220).
|
|
75
|
-
- **Two-Column Drawer Layout**: Адаптивный двухколоночный макет для TaskDrawer на экранах шириной от 960px с разделением метаданных и контентной области (#219).
|
|
76
|
-
- **Multi-Repo Workspace Mirroring**: Сканирование нескольких вложенных git-репозиториев (`findWorkspaceGitRepos`) и подготовка зеркальных воркдеревьев на единой ветке задачи (`prepareMultiRepoMirror`) (#210).
|
|
77
|
-
|
|
78
|
-
### 2026-09-08: Релиз Vector 4 (UX оператора, управление моделями и аналитика) (#217, #218, #221, #223, #228)
|
|
79
|
-
- **reasoningEffort**: Добавлен селектор глубины рассуждения (low, medium, high) в карточке `TaskDrawer`, сохраняется в SQLite и передается в метаданные сессии DSH для моделей с рассуждением (DeepSeek-R1, OpenAI o1/o3).
|
|
80
|
-
- **Экспорт в CSV с UTF-8 BOM**: Добавлен эндпоинт `GET /dsh-kanban/export.csv` и кнопка в тулбаре для выгрузки аналитики задач в Microsoft Excel без искажения кодировки.
|
|
81
|
-
- **Адаптивный 36x36 Rail Icon**: Кнопка канбана в сайдбаре автоматически принимает компактный размер 36×36 px с центрированием иконки при сворачивании панели в rail.
|
|
82
|
-
- **Lightbox для изображений**: Превью Markdown-изображений в карточке открываются в модальном полноэкранном лайтбоксе.
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
### 2026-09-08: Релиз Vector 3 (Просмотр Git Diff / Коммитов и Однокликовый Merge) (#211, #212)
|
|
86
|
-
- **Git Diff & Commits Viewer**: В `TaskDrawer` интегрирован блок просмотра истории коммитов сессии и unified diff измененных файлов воркдерева (`GET /tasks/:id/diff` и `GET /tasks/:id/commits`). Diff безопасно ограничен 250 КБ.
|
|
87
|
-
- **One-Click Merge (--no-ff)**: В секции Review Acceptance Gate добавлена кнопка «✓ Принять и смержить» (`POST /tasks/:id/merge`). Выполняется проверка на отсутствие незакоммиченных изменений (`checkWorktreeDirty`), слияние `git merge --no-ff` в целевую ветку (`main`), автоматическая дерегистрация и удаление воркдерева с переводом задачи в `done`.
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
### 2026-09-08: Релиз Vector 2 (Управление параллелизмом и безопасность) (#214, #216, #224)
|
|
91
|
-
- **maxConcurrentSessions**: Лимит параллельных сессий агентов (по умолчанию 3) предотвращает исчерпание памяти/CPU на хосте. Задачи сверх лимита переводятся в очередь (`queuedAt > 0`) и автоматически запускаются диспетчером при завершении активных сессий.
|
|
92
|
-
- **workspaceClaimBoundaries**: Инструмент `board_move` проверяет принадлежность задачи к воркспейсу текущей сессии агента, предотвращая случайный межпроектный захват карточек.
|
|
93
|
-
- **Страховочный бэкап при импорте снимка**: `importBoard` в `/dsh-kanban/snapshot` автоматически делает снимок базы SQLite в `backups/kanban-backup-<timestamp>.db` с ротацией последних 10 копий перед перезаписью данных.
|