@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/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) => withPlan(task, liveOf, config, now, bySession.get(task.sessionId) ?? [])),
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', 'runs', 'checklist', 'report', 'comments', 'rejectReason', 'rejectedAt',
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.1",
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
- "docs/",
17
+ "README.zh.md",
18
+ "README.ru.md",
18
19
  "LICENSE"
19
20
  ],
20
21
  "scripts": {
@@ -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 копий перед перезаписью данных.