@goodandready/dsh-kanban 0.1.25 → 0.1.27

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 CHANGED
@@ -275,6 +275,14 @@ visibility and multi-agent concurrency protection. During the `cleanup` phase, u
275
275
  are detected via `git status --porcelain` to prevent data loss, followed by clean worktree and workspace
276
276
  deregistration.
277
277
 
278
+ ## ⚙️ Host-Side Automation & Governance (v0.1.26+)
279
+
280
+ - **Host-Side Cron Scheduler**: Autonomous 5-field cron parsing (`分 时 日 月 周`) executed in the host Node.js process. Tasks trigger reliably in the background without needing an open browser tab.
281
+ - **Power Inhibitor (`preventIdleSleep`)**: Cross-platform system idle sleep prevention (PowerShell Win32 `SetThreadExecutionState` on Windows, `/usr/bin/systemd-inhibit` on Linux, `/usr/bin/caffeinate` on macOS) active while tasks or sessions run.
282
+ - **PROGRESSDUMP Handover**: Structured progress snapshots (`<<<PROGRESSDUMP ... >>>PROGRESSDUMP`) with secret redaction (`[REDACTED]`) and slash-command suppression, formatting clean task handover preambles for subsequent agents.
283
+ - **Permission Confirmation Gate**: Tasks requesting permissions exceeding the configured baseline (`sessionDefaultPermission`) enter a gated state requiring explicit human confirmation before launch. Modifying parameters resets the gate.
284
+ - **Task Execution History (`runs`)**: Last 20 execution attempts per task stored directly inside SQLite `tasks.runs`, tracking duration, outcome (`succeeded`, `failed`, `cancelled`), model, and direct links to session chats.
285
+
278
286
  ## 📄 License
279
287
 
280
288
  MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
@@ -51,3 +51,4 @@
51
51
  - 2026-08-25 — Использование дробной индексации ($O(1)$) для позиционирования карточек.
52
52
  - 2026-09-02 — Снимок доски объединён на маршруте /dsh-kanban/snapshot (GET: выгрузка, POST: загрузка).
53
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).
package/lib/client.js CHANGED
@@ -862,6 +862,26 @@ window.__ModuleLoader__.load({
862
862
  'hint.worktreeIsolation': 'Creates a separate git worktree for each task session to prevent file collisions.',
863
863
  'field.worktreeRoot': 'Worktree root directory',
864
864
  'hint.worktreeRoot': 'Custom folder for task worktrees. Leave empty to use $DSH_HOME/worktrees.',
865
+ 'field.cronEnabled': 'Enable host-side task cron',
866
+ 'hint.cronEnabled': 'Runs tasks on scheduled 5-field cron on the host even when browser is closed.',
867
+ 'field.preventIdleSleep': 'Prevent system idle sleep',
868
+ 'hint.preventIdleSleep': 'Keeps the host awake while tasks are running or armed.',
869
+ 'field.progressDumpEnabled': 'Enable PROGRESSDUMP handovers',
870
+ 'hint.progressDumpEnabled': 'Captures session progress dumps and provides clean preambles to subsequent agents.',
871
+ 'field.permissionGateEnabled': 'Enable permission confirmation gate',
872
+ 'hint.permissionGateEnabled': 'Requires manual user confirmation before launching tasks with elevated permissions.',
873
+ 'field.sessionDefaultPermission': 'Default permission baseline',
874
+ 'hint.sessionDefaultPermission': 'Tasks requesting permissions above this rank require confirmation.',
875
+ 'field.announceToAgent': 'Announce board in system prompt',
876
+ 'hint.announceToAgent': 'Injects guidance section into the agent system prompt.',
877
+ 'task.runs': 'Execution History',
878
+ 'task.noRuns': 'No runs yet',
879
+ 'task.cron': 'Cron schedule',
880
+ 'task.nextRun': 'Next run: {date}',
881
+ 'task.progressDump': 'Progress Dump (Handover)',
882
+ 'task.permissionGateWarning': 'Requires confirmation for elevated permission ({permission})',
883
+ 'task.confirmPermission': 'Confirm Permission',
884
+ 'task.permissionConfirmed': 'Permission confirmed',
865
885
  'hint.pushToGitea': 'A card reaching Done closes its issue. The board sets no labels: the stage is already visible from the branch and the pull request.',
866
886
  'hint.webhookSecretRef': 'NAME of a DSH credential holding the Gitea webhook secret. Empty leaves only polling.',
867
887
  'hint.syncIntervalSec': 'Zero turns the check off. Below fifteen seconds is raised to fifteen.',
@@ -1136,6 +1156,26 @@ window.__ModuleLoader__.load({
1136
1156
  'hint.worktreeIsolation': '为每个任务会话创建独立的 git worktree 以防止文件冲突。',
1137
1157
  'field.worktreeRoot': 'Worktree 根目录',
1138
1158
  'hint.worktreeRoot': '任务 worktree 的自定义目录。留空则使用 $DSH_HOME/worktrees。',
1159
+ 'field.cronEnabled': '启用 Host 端任务定时 (Cron)',
1160
+ 'hint.cronEnabled': '在 Host 端按 5 段 cron 自动运行任务,关闭浏览器仍生效。',
1161
+ 'field.preventIdleSleep': '防止系统空闲睡眠',
1162
+ 'hint.preventIdleSleep': '在任务进行或定时启用期间阻止系统进入空闲睡眠。',
1163
+ 'field.progressDumpEnabled': '启用 PROGRESSDUMP 进度交接',
1164
+ 'hint.progressDumpEnabled': '提取会话中的进度切片,向接续会话注入清晰的任务前置上下文。',
1165
+ 'field.permissionGateEnabled': '启用权限确认门禁',
1166
+ 'hint.permissionGateEnabled': '需要人工确认后才允许启动高于默认权限的任务。',
1167
+ 'field.sessionDefaultPermission': '基线默认权限',
1168
+ 'hint.sessionDefaultPermission': '超出此权限的任务需要点击人工确认。',
1169
+ 'field.announceToAgent': '在系统提示词中声明看板',
1170
+ 'hint.announceToAgent': '向 Agent 系统提示词注入看板与进度协作说明。',
1171
+ 'task.runs': '执行历史',
1172
+ 'task.noRuns': '暂无执行记录',
1173
+ 'task.cron': '定时表达式 (Cron)',
1174
+ 'task.nextRun': '下次执行: {date}',
1175
+ 'task.progressDump': '进度快照 (交接)',
1176
+ 'task.permissionGateWarning': '该任务需要确认较高权限 ({permission})',
1177
+ 'task.confirmPermission': '确认权限',
1178
+ 'task.permissionConfirmed': '权限已确认',
1139
1179
  'hint.pushToGitea': '卡片进入「已完成」即关闭其 issue。看板不设置任何标签:阶段从分支和 pull request 上已经看得出来。',
1140
1180
  'hint.webhookSecretRef': '保存 Gitea webhook 密钥的 DSH 凭据的名称。留空则只剩轮询。',
1141
1181
  'hint.syncIntervalSec': '填 0 关闭核对。小于十五秒会被提到十五秒。',
@@ -1411,6 +1451,12 @@ window.__ModuleLoader__.load({
1411
1451
  { field: 'webhookSecretRef', kind: 'text', group: 'sync' },
1412
1452
  { field: 'worktreeIsolation', kind: 'bool', group: 'general' },
1413
1453
  { field: 'worktreeRoot', kind: 'text', group: 'general' },
1454
+ { field: 'cronEnabled', kind: 'bool', group: 'sync' },
1455
+ { field: 'preventIdleSleep', kind: 'bool', group: 'general' },
1456
+ { field: 'progressDumpEnabled', kind: 'bool', group: 'general' },
1457
+ { field: 'permissionGateEnabled', kind: 'bool', group: 'general' },
1458
+ { field: 'sessionDefaultPermission', kind: 'text', group: 'general' },
1459
+ { field: 'announceToAgent', kind: 'bool', group: 'general' },
1414
1460
  ]
1415
1461
 
1416
1462
  function hintKey(spec) {
@@ -1632,6 +1678,8 @@ window.__ModuleLoader__.load({
1632
1678
  waiting ? React.createElement('span', { className: 'dkb-waitDot' }) : null,
1633
1679
  waiting ? React.createElement('span', { className: 'dkb-waiting' }, t('board.waiting')) : null,
1634
1680
  taskRef(task, t),
1681
+ task.cron ? React.createElement('span', { className: 'dkb-tag', title: task.cron }, '⏰ cron') : null,
1682
+ task.progressDump ? React.createElement('span', { className: 'dkb-tag', title: 'PROGRESSDUMP' }, '📋 dump') : null,
1635
1683
  (task.labels || []).map((l) => React.createElement('span', {
1636
1684
  className: 'dkb-tag', key: l, style: tagStyle(task, l),
1637
1685
  }, l))),
@@ -1784,6 +1832,7 @@ window.__ModuleLoader__.load({
1784
1832
  const [editPriority, setEditPriority] = React.useState('')
1785
1833
  const [editLabels, setEditLabels] = React.useState('')
1786
1834
  const [editDue, setEditDue] = React.useState('')
1835
+ const [editCron, setEditCron] = React.useState('')
1787
1836
  const [overColumn, setOverColumn] = React.useState('')
1788
1837
  const [model, setModel] = React.useState('')
1789
1838
  const [provider, setProvider] = React.useState('')
@@ -1997,6 +2046,7 @@ window.__ModuleLoader__.load({
1997
2046
  setEditDue(typeof task?.dueAt === 'number' && task.dueAt > 0
1998
2047
  ? new Date(task.dueAt).toISOString().slice(0, 10)
1999
2048
  : '')
2049
+ setEditCron(typeof task?.cron === 'string' ? task.cron : '')
2000
2050
  api('/task/' + encodeURIComponent(task.id) + '/body')
2001
2051
  .then((out) => setBody((out && out.blocks) || []))
2002
2052
  .catch(() => setBody([]))
@@ -2528,6 +2578,25 @@ window.__ModuleLoader__.load({
2528
2578
  t('panel.worktree', { path: openTask.worktree })
2529
2579
  + (openTask.branch ? ' (' + openTask.branch + ')' : ''))
2530
2580
  : null,
2581
+ openTask.permission && !openTask.permissionConfirmedAt
2582
+ ? React.createElement('div', {
2583
+ className: 'dkb-warnBox',
2584
+ style: { margin: '4px 0', padding: 10, background: 'var(--dsw-alias-state-warn-bg, #fff3cd)', border: '1px solid var(--dsw-alias-state-warn-border, #ffeeba)', borderRadius: 6 },
2585
+ },
2586
+ React.createElement('div', { style: { marginBottom: 6, fontSize: 13, fontWeight: 500, color: 'var(--dsw-alias-state-warn-primary, #856404)' } },
2587
+ t('task.permissionGateWarning', { permission: openTask.permission })),
2588
+ React.createElement('button', {
2589
+ type: 'button', className: 'dkb-save',
2590
+ onClick: async () => {
2591
+ try {
2592
+ const out = await api('/task/' + encodeURIComponent(openTask.id) + '/confirm-permission', { method: 'POST' })
2593
+ if (out && out.task) setOpenTask(out.task)
2594
+ await load(board)
2595
+ } catch (e) { setError(t(e.key || 'error.unknown')) }
2596
+ },
2597
+ }, t('task.confirmPermission')),
2598
+ )
2599
+ : null,
2531
2600
  // Пустое тело — отсутствие раздела, а не пустой раздел с заголовком.
2532
2601
  body === null
2533
2602
  ? React.createElement('p', { className: 'dkb-note' }, t('panel.bodyLoading'))
@@ -2562,6 +2631,18 @@ window.__ModuleLoader__.load({
2562
2631
  onChange: (e) => setEditDue(e.target.value),
2563
2632
  }),
2564
2633
  ),
2634
+ React.createElement('div', { className: 'dkb-field' },
2635
+ React.createElement('span', { className: 'dkb-label' }, t('task.cron')),
2636
+ React.createElement('input', {
2637
+ className: 'dkb-input', value: editCron,
2638
+ placeholder: '* * * * *',
2639
+ onChange: (e) => setEditCron(e.target.value),
2640
+ }),
2641
+ openTask.nextRunAt
2642
+ ? React.createElement('p', { className: 'dkb-hint' },
2643
+ t('task.nextRun', { date: new Date(openTask.nextRunAt).toLocaleString() }))
2644
+ : null,
2645
+ ),
2565
2646
  React.createElement('button', {
2566
2647
  type: 'button', className: 'dkb-save', style: { alignSelf: 'flex-start' },
2567
2648
  onClick: async () => {
@@ -2574,7 +2655,7 @@ window.__ModuleLoader__.load({
2574
2655
  : new Date(editDue + 'T00:00:00').getTime()
2575
2656
  const out = await api('/task/' + encodeURIComponent(openTask.id) + '/update', {
2576
2657
  method: 'POST',
2577
- body: JSON.stringify({ priority: editPriority, labels, dueAt }),
2658
+ body: JSON.stringify({ priority: editPriority, labels, dueAt, cron: editCron }),
2578
2659
  })
2579
2660
  setOpenTask(out.task)
2580
2661
  await load(board)
@@ -2582,6 +2663,47 @@ window.__ModuleLoader__.load({
2582
2663
  },
2583
2664
  }, t('panel.saveEdit')),
2584
2665
  ),
2666
+ openTask.progressDump
2667
+ ? React.createElement('div', null,
2668
+ React.createElement('div', { className: 'dkb-group' }, t('task.progressDump')),
2669
+ React.createElement('pre', {
2670
+ style: {
2671
+ background: 'var(--dsw-alias-bg-layer-1, #f6f8fa)', padding: 8, borderRadius: 6, fontSize: 12,
2672
+ whiteSpace: 'pre-wrap', maxHeight: 150, overflowY: 'auto', margin: 0,
2673
+ },
2674
+ }, openTask.progressDump),
2675
+ )
2676
+ : null,
2677
+ openTask.runs && openTask.runs.length
2678
+ ? React.createElement('div', null,
2679
+ React.createElement('div', { className: 'dkb-group' }, t('task.runs')),
2680
+ React.createElement('ul', { className: 'dkb-log' }, openTask.runs.map((r) => React.createElement('li', {
2681
+ className: 'dkb-logRow', key: r.id,
2682
+ style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' },
2683
+ },
2684
+ React.createElement('span', null,
2685
+ (r.result === 'succeeded' ? '✓ ' : r.result === 'failed' ? '✕ ' : '▸ ')
2686
+ + new Date(r.startedAt).toLocaleTimeString()
2687
+ + (r.model ? ' · ' + r.model : '')
2688
+ + (r.error ? ' · ' + r.error : '')
2689
+ ),
2690
+ r.sessionId
2691
+ ? React.createElement('button', {
2692
+ type: 'button',
2693
+ className: 'dkb-discard',
2694
+ style: { padding: '2px 6px', fontSize: 11 },
2695
+ onClick: () => {
2696
+ if (!openSession(props.ctx, r.sessionId)) {
2697
+ setError(t('error.sessionNotOpened', { id: r.sessionId }))
2698
+ return
2699
+ }
2700
+ setOpenTask(null)
2701
+ if (props.onClose) props.onClose()
2702
+ },
2703
+ }, t('panel.openChat'))
2704
+ : null,
2705
+ ))))
2706
+ : null,
2585
2707
  // Взять и отдать — одна кнопка в двух состояниях: держать рядом
2586
2708
  // «взять» и «отказаться» значит предлагать бессмысленное действие.
2587
2709
  React.createElement('button', {
package/lib/config.js CHANGED
@@ -73,6 +73,12 @@ export const CONFIG_DEFAULTS = {
73
73
  pushToGitea: true,
74
74
  worktreeIsolation: true,
75
75
  worktreeRoot: '',
76
+ cronEnabled: false,
77
+ preventIdleSleep: false,
78
+ progressDumpEnabled: false,
79
+ permissionGateEnabled: false,
80
+ sessionDefaultPermission: 'read-only',
81
+ announceToAgent: true,
76
82
  }
77
83
 
78
84
  /** Пояснения к полям; повторно используются схемой и карточкой настроек. */
@@ -95,6 +101,12 @@ export const CONFIG_HINTS = {
95
101
  pushToGitea: 'Закрывать issue в Gitea, когда карточка уходит в колонку «Выполнено».',
96
102
  worktreeIsolation: 'Изолировать сессии задач в отдельные git worktrees для предотвращения коллизий.',
97
103
  worktreeRoot: 'Каталог для воркдеревьев задач. Пусто — $DSH_HOME/worktrees или .worktrees проекта.',
104
+ cronEnabled: 'Включить фоновый запуск задач по расписанию cron на стороне хоста.',
105
+ preventIdleSleep: 'Запретить переход хоста в спящий режим, пока есть активные сессии задач.',
106
+ progressDumpEnabled: 'Включить поддержку фиксации срезов PROGRESSDUMP для передачи эстафеты между сессиями.',
107
+ permissionGateEnabled: 'Требовать подтверждения человеком для запуска задач с повышенными правами.',
108
+ sessionDefaultPermission: 'Базовый уровень прав без подтверждения. Права выше этого требуют одобрения.',
109
+ announceToAgent: 'Добавлять секцию о доске задач в системный промпт агентов.',
98
110
  }
99
111
 
100
112
  /** Поле настроек с пределом числа карточек; задан не у всех колонок. */
package/lib/cron.js ADDED
@@ -0,0 +1,154 @@
1
+ // dsh-kanban: парсер 5-позиционного cron и вычисление следующего запуска.
2
+ // Без внешних зависимостей: чистые функции над датами и числами.
3
+
4
+ const FIELD_RANGES = [
5
+ [0, 59], // минуты
6
+ [0, 23], // часы
7
+ [1, 31], // дни месяца
8
+ [1, 12], // месяцы
9
+ [0, 7], // дни недели (0 и 7 — воскресенье)
10
+ ]
11
+
12
+ function parseField(token, min, max, targetSet) {
13
+ if (token === '*') {
14
+ for (let i = min; i <= max; i++) targetSet.add(i)
15
+ return true
16
+ }
17
+
18
+ for (const part of token.split(',')) {
19
+ if (part === '') return false
20
+
21
+ if (part.includes('/')) {
22
+ const [rangePart, stepStr] = part.split('/')
23
+ const step = Number(stepStr)
24
+ if (!Number.isInteger(step) || step <= 0) return false
25
+
26
+ let start = min
27
+ let end = max
28
+ if (rangePart !== '*' && rangePart !== '') {
29
+ if (rangePart.includes('-')) {
30
+ const [sStr, eStr] = rangePart.split('-')
31
+ start = Number(sStr)
32
+ end = Number(eStr)
33
+ } else {
34
+ start = Number(rangePart)
35
+ end = max
36
+ }
37
+ }
38
+ if (!Number.isInteger(start) || !Number.isInteger(end) || start < min || end > max || start > end) {
39
+ return false
40
+ }
41
+ for (let i = start; i <= end; i += step) targetSet.add(i)
42
+ continue
43
+ }
44
+
45
+ if (part.includes('-')) {
46
+ const [sStr, eStr] = part.split('-')
47
+ const start = Number(sStr)
48
+ const end = Number(eStr)
49
+ if (!Number.isInteger(start) || !Number.isInteger(end) || start < min || end > max || start > end) {
50
+ return false
51
+ }
52
+ for (let i = start; i <= end; i++) targetSet.add(i)
53
+ continue
54
+ }
55
+
56
+ const val = Number(part)
57
+ if (!Number.isInteger(val) || val < min || val > max) return false
58
+ targetSet.add(val)
59
+ }
60
+
61
+ return targetSet.size > 0
62
+ }
63
+
64
+ /**
65
+ * Разобрать 5-позиционное выражение cron: "минута час день месяц день_недели".
66
+ * @param {string} expr
67
+ * @returns {object|null} распарсенное расписание или null, если выражение невалидно
68
+ */
69
+ export function parseCron(expr) {
70
+ if (typeof expr !== 'string') return null
71
+ const fields = expr.trim().split(/\s+/)
72
+ if (fields.length !== 5) return null
73
+
74
+ const sets = []
75
+ for (let index = 0; index < 5; index++) {
76
+ const [min, max] = FIELD_RANGES[index]
77
+ const set = new Set()
78
+ if (!parseField(fields[index], min, max, set)) return null
79
+ sets.push(set)
80
+ }
81
+
82
+ const weekdays = new Set()
83
+ for (const day of sets[4]) weekdays.add(day === 7 ? 0 : day)
84
+
85
+ return {
86
+ minutes: sets[0],
87
+ hours: sets[1],
88
+ days: sets[2],
89
+ months: sets[3],
90
+ weekdays,
91
+ dayWildcard: fields[2] === '*',
92
+ weekdayWildcard: fields[4] === '*',
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Вычислить время следующего запуска (в миллисекундах epoch) строго после `fromMs`.
98
+ * Поиск ограничен 5 годами вперед, чтобы избежать бесконечного цикла.
99
+ * @param {string|object} exprOrSchedule
100
+ * @param {number} [fromMs]
101
+ * @returns {number|undefined} время следующего запуска или undefined
102
+ */
103
+ export function computeNextRun(exprOrSchedule, fromMs = Date.now()) {
104
+ const schedule = typeof exprOrSchedule === 'string' ? parseCron(exprOrSchedule) : exprOrSchedule
105
+ if (!schedule) return undefined
106
+
107
+ // Стартуем со следующей целой минуты
108
+ const current = new Date(fromMs)
109
+ current.setSeconds(0, 0)
110
+ current.setMinutes(current.getMinutes() + 1)
111
+
112
+ const limitMs = fromMs + 5 * 365 * 24 * 60 * 60 * 1000
113
+
114
+ while (current.getTime() <= limitMs) {
115
+ const month = current.getMonth() + 1
116
+ if (!schedule.months.has(month)) {
117
+ current.setMonth(current.getMonth() + 1, 1)
118
+ current.setHours(0, 0, 0, 0)
119
+ continue
120
+ }
121
+
122
+ const dayOfMonth = current.getDate()
123
+ const dayOfWeek = current.getDay()
124
+ const dayMatches = schedule.dayWildcard && schedule.weekdayWildcard
125
+ ? true
126
+ : schedule.dayWildcard
127
+ ? schedule.weekdays.has(dayOfWeek)
128
+ : schedule.weekdayWildcard
129
+ ? schedule.days.has(dayOfMonth)
130
+ : schedule.days.has(dayOfMonth) || schedule.weekdays.has(dayOfWeek)
131
+
132
+ if (!dayMatches) {
133
+ current.setDate(current.getDate() + 1)
134
+ current.setHours(0, 0, 0, 0)
135
+ continue
136
+ }
137
+
138
+ const hour = current.getHours()
139
+ if (!schedule.hours.has(hour)) {
140
+ current.setHours(current.getHours() + 1, 0, 0, 0)
141
+ continue
142
+ }
143
+
144
+ const minute = current.getMinutes()
145
+ if (!schedule.minutes.has(minute)) {
146
+ current.setMinutes(current.getMinutes() + 1)
147
+ continue
148
+ }
149
+
150
+ return current.getTime()
151
+ }
152
+
153
+ return undefined
154
+ }
package/lib/index.js CHANGED
@@ -23,11 +23,13 @@ import {
23
23
  buildBoard, applyMove, createTask, updateTask, deleteTask, appendNote, setArchived, listArchive,
24
24
  taskBody,
25
25
  taskLog, taskBySession, exportBoard, importBoard, assignTask, boardMetrics,
26
+ confirmPermission,
26
27
  } from './routes.js'
27
28
  import { listImportable, importIssue, refreshTask, searchRepos, createProjectTask } from './import.js'
28
29
  import {
29
30
  runTask, resolveModel, buildStartMessage,
30
31
  runBatch, queueTask, unqueueTask, liveSessions,
32
+ PERMISSION_RANKS,
31
33
  } from './launcher.js'
32
34
  import { cleanupTaskWorktree, checkWorktreeDirty } from './worktree.js'
33
35
  import { dispatchMove, MOVE_DETAIL, stopWork, STOP_DETAIL } from './commands.js'
@@ -38,6 +40,9 @@ import { handleSessionEvent } from './lifecycle.js'
38
40
  import { boardMoveDefinition, boardPlanDefinition } from './board-tool.js'
39
41
  import { verifySignature, parseEvent } from './webhook.js'
40
42
  import { planOutbound, planAssign, createOutbox } from './outbound.js'
43
+ import { computeNextRun } from './cron.js'
44
+ import { PowerInhibitor } from './power.js'
45
+ import { formatProgressDump, parseProgressDump } from './progress-dump.js'
41
46
  import { credentialRef } from '@deepseek-ai/dsh-credentials'
42
47
 
43
48
  export { COLUMN_ORDER, wipLimitField, withDefaults } from './config.js'
@@ -197,17 +202,140 @@ export function apply(ctx, config) {
197
202
  return () => { try { store.close() } catch { /* уже закрыта */ } }
198
203
  }, 'dsh-kanban: хранилище задач')
199
204
 
200
- // Признак ожидания по событиям сессии. Задача ищется по идентификатору
201
- // сессии; сессий не с доски большинство, и для них здесь ничего не
202
- // происходит — это штатный ход, а не пропуск.
205
+ // Кроссплатформенный ингибитор сна системы во время работы задач
206
+ const powerInhibitor = new PowerInhibitor({
207
+ platform: process.platform,
208
+ pid: process.pid,
209
+ })
210
+
211
+ const syncPower = () => {
212
+ try {
213
+ const cfg = getConfig()
214
+ powerInhibitor.setEnabled(cfg.preventIdleSleep === true)
215
+ if (store) {
216
+ const activeCount = store.countInColumn('main', 'in-progress') + store.countInColumn('simple', 'in-progress')
217
+ powerInhibitor.setReasons(activeCount)
218
+ }
219
+ } catch { /* игнорируем */ }
220
+ }
221
+ rearmers.add(syncPower)
222
+ ctx.effect(() => {
223
+ syncPower()
224
+ return () => { powerInhibitor.destroy() }
225
+ }, 'dsh-kanban: ингибитор сна')
226
+
227
+ // Инъекция секции доски в системный промпт агента
228
+ let disposePromptSection
229
+ const syncPromptSection = () => {
230
+ if (disposePromptSection) {
231
+ try { disposePromptSection() } catch {}
232
+ disposePromptSection = undefined
233
+ }
234
+ const cfg = getConfig()
235
+ const sp = ctx.get('systemPrompt')
236
+ if (cfg.announceToAgent !== false && sp && typeof sp.section === 'function') {
237
+ try {
238
+ disposePromptSection = sp.section({
239
+ name: 'plugin:dsh-kanban',
240
+ order: 200,
241
+ text: 'В системе развернута доска задач dsh-kanban. При работе над задачами вы можете перемещать карточки инструментом board_move и вести чек-лист плана через board_plan. При исчерпании контекста или завершении подэтапа сформируйте блок <<<PROGRESSDUMP\\nЦель: ...\\nПрогресс: ...\\nСледующие шаги: ...\\n>>>PROGRESSDUMP для передачи эстафеты следующей сессии.',
242
+ })
243
+ } catch (err) {
244
+ ctx.logger?.warn?.(`dsh-kanban: не удалось зарегистрировать секцию промпта: ${err?.message}`)
245
+ }
246
+ }
247
+ }
248
+ rearmers.add(syncPromptSection)
249
+ ctx.effect(() => {
250
+ syncPromptSection()
251
+ return () => { if (disposePromptSection) { try { disposePromptSection() } catch {} } }
252
+ }, 'dsh-kanban: секция системного промпта')
253
+
254
+ // Признак ожидания, исходы сессий и PROGRESSDUMP по событиям сессии.
203
255
  ctx.effect(() => ctx.on('session/event', (session, event) => {
204
256
  try {
205
- handleSessionEvent({ store, sessionId: String(session?.id ?? ''), type: event?.type })
257
+ const text = event?.content?.[0]?.text ?? event?.text ?? ''
258
+ handleSessionEvent({
259
+ store,
260
+ sessionId: String(session?.id ?? ''),
261
+ type: event?.type,
262
+ text,
263
+ error: event?.error,
264
+ })
265
+ syncPower()
206
266
  } catch (error) {
207
267
  ctx.logger?.warn?.(`dsh-kanban: событие сессии не обработано: ${error?.message}`)
208
268
  }
209
269
  }), 'dsh-kanban: события сессии')
210
270
 
271
+ // Фоновый шедулер cron для задач
272
+ ctx.effect(() => {
273
+ let cronTimer
274
+ const armCron = () => {
275
+ clearInterval(cronTimer)
276
+ cronTimer = undefined
277
+ if (getConfig().cronEnabled !== true) return
278
+ cronTimer = setInterval(async () => {
279
+ try {
280
+ if (!store) return
281
+ const now = Date.now()
282
+ const dueTasks = store.listDueTasks(now)
283
+ for (const task of dueTasks) {
284
+ if (task.column === 'in-progress') {
285
+ const next = computeNextRun(task.cron, now)
286
+ store.updateTask(task.id, { lastRunAt: now, nextRunAt: next || 0 })
287
+ continue
288
+ }
289
+ if (getConfig().permissionGateEnabled) {
290
+ const perm = task.permission || 'read-only'
291
+ const defaultPerm = getConfig().sessionDefaultPermission || 'read-only'
292
+ if ((PERMISSION_RANKS[perm] ?? 0) > (PERMISSION_RANKS[defaultPerm] ?? 0) && !task.permissionConfirmedAt) {
293
+ const next = computeNextRun(task.cron, now)
294
+ store.updateTask(task.id, { lastRunAt: now, nextRunAt: next || 0 })
295
+ store.addTransition({
296
+ taskId: task.id,
297
+ fromCol: task.column,
298
+ toCol: task.column,
299
+ source: 'tool',
300
+ detail: 'cron: запуск отклонён (требуется подтверждение повышенных прав)',
301
+ })
302
+ continue
303
+ }
304
+ }
305
+ const picked = resolveModel({
306
+ requested: { model: task.model, provider: task.provider },
307
+ fallback: ctx.get('agentDefaultModel')?.currentSelection?.(),
308
+ })
309
+ if (picked.model) {
310
+ await runTask({
311
+ agents: ctx.agents,
312
+ store,
313
+ task,
314
+ config: getConfig(),
315
+ provider: picked.provider,
316
+ model: picked.model,
317
+ mintSessionId: () => SessionId(`kanban-${task.id}-${randomUUID()}`),
318
+ createMessage: createUserMessage,
319
+ logger: ctx.logger,
320
+ workspaceRegistry: ctx.get('workspaceRegistry'),
321
+ claimAs: 'cron',
322
+ })
323
+ syncPower()
324
+ }
325
+ const next = computeNextRun(task.cron, now)
326
+ store.updateTask(task.id, { lastRunAt: now, nextRunAt: next || 0 })
327
+ }
328
+ } catch (err) {
329
+ ctx.logger?.warn?.(`dsh-kanban: ошибка шедулера cron: ${err?.message}`)
330
+ }
331
+ }, 30_000)
332
+ if (typeof cronTimer.unref === 'function') cronTimer.unref()
333
+ }
334
+ armCron()
335
+ rearmers.add(armCron)
336
+ return () => { rearmers.delete(armCron); clearInterval(cronTimer) }
337
+ }, 'dsh-kanban: шедулер cron')
338
+
211
339
  // Периодическая сверка с Gitea. Ноль в настройках отключает её целиком, а
212
340
  // перекрытие запусков не допускается: медленный инстанс иначе копил бы
213
341
  // очередь сверок вместо одной идущей.
@@ -646,7 +774,7 @@ export function apply(ctx, config) {
646
774
  fallback: ctx.get('agentDefaultModel')?.currentSelection?.(),
647
775
  })
648
776
  if (picked.error) { writeJson(res, picked.status, { error: picked.error }); return }
649
- reply(res, await runTask({
777
+ const started = await runTask({
650
778
  agents: ctx.agents,
651
779
  store,
652
780
  task,
@@ -664,7 +792,9 @@ export function apply(ctx, config) {
664
792
  permissions: ctx.get('permissionPresets'),
665
793
  workspaceRegistry: ctx.get('workspaceRegistry'),
666
794
  claimAs: await whoAmI(),
667
- }))
795
+ })
796
+ syncPower()
797
+ reply(res, started)
668
798
  } catch (error) {
669
799
  ctx.logger?.warn?.(`dsh-kanban: запуск задачи не удался: ${error?.message}`)
670
800
  // Отказ по уровню доступа называется своим именем: «не удалось» на
@@ -736,11 +866,28 @@ export function apply(ctx, config) {
736
866
  })
737
867
  return
738
868
  }
869
+ if (req.method === 'POST' && action === 'confirm-permission') {
870
+ reply(res, confirmPermission({ store, id }))
871
+ return
872
+ }
873
+ if (req.method === 'POST' && action === 'progress-dump') {
874
+ const task = store.getTask(id)
875
+ if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
876
+ const formatted = typeof input === 'string' ? input : formatProgressDump(input)
877
+ const updated = store.updateTask(id, { progressDump: formatted })
878
+ reply(res, { task: updated })
879
+ return
880
+ }
739
881
  if (req.method === 'POST' && action === 'stop') {
740
882
  const task = store.getTask(id)
741
883
  if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
742
884
  const out = stopWork({ agents: ctx.agents, task, logger: ctx.logger })
743
885
  if (out.acted === 'stopped') {
886
+ const latestRun = Array.isArray(task.runs) ? task.runs[0] : undefined
887
+ if (latestRun && !latestRun.endedAt && typeof store.updateTaskRun === 'function') {
888
+ store.updateTaskRun(task.id, latestRun.id, { endedAt: Date.now(), result: 'cancelled' })
889
+ }
890
+ syncPower()
744
891
  // Остановка — событие, и в журнале ей место наравне с переносом:
745
892
  // иначе прерванная работа выглядит просто оборвавшейся сама.
746
893
  store.addTransition({
package/lib/launcher.js CHANGED
@@ -13,7 +13,11 @@
13
13
  // приходят параметрами. Так модуль проверяется без харнесса и без сети.
14
14
 
15
15
  import { isAbsolute, join, resolve, sep } from 'node:path'
16
+ import { randomUUID } from 'node:crypto'
16
17
  import { createTaskWorktree, registerDshWorkspace } from './worktree.js'
18
+ import { formatHandoverPreamble, parseProgressDump } from './progress-dump.js'
19
+
20
+ export const PERMISSION_RANKS = { 'read-only': 0, 'workspace-write': 1, 'danger-full-access': 2 }
17
21
 
18
22
  /**
19
23
  * Хвост своей задачи — УСЛОВИЕ, а не приказ.
@@ -291,11 +295,35 @@ export async function runTask({
291
295
  agentPreset, permission, permissions, claimAs,
292
296
  gitRunner, workspaceRegistry,
293
297
  }) {
298
+ if (config?.permissionGateEnabled) {
299
+ const perm = permission || task.permission || 'read-only'
300
+ const defaultPerm = config?.sessionDefaultPermission || 'read-only'
301
+ if ((PERMISSION_RANKS[perm] ?? 0) > (PERMISSION_RANKS[defaultPerm] ?? 0)) {
302
+ if (!task.permissionConfirmedAt || task.permissionConfirmedAt === 0) {
303
+ const err = new Error('Требуется подтверждение прав')
304
+ err.key = 'permission-unconfirmed'
305
+ err.status = 403
306
+ throw err
307
+ }
308
+ }
309
+ }
310
+
294
311
  const taken = await obtainAgent({
295
312
  agents, task, config, provider, model, mintSessionId, cwdOf, logger,
296
313
  agentPreset, permission, permissions, gitRunner, workspaceRegistry,
297
314
  })
298
315
 
316
+ // Фиксируем запуск в истории задачи
317
+ if (typeof store.recordTaskRun === 'function') {
318
+ store.recordTaskRun(task.id, {
319
+ id: randomUUID(),
320
+ sessionId: taken.sessionId,
321
+ startedAt: Date.now(),
322
+ model,
323
+ provider,
324
+ })
325
+ }
326
+
299
327
  await taken.agent.whenIdle()
300
328
  taken.agent.followup(createMessage({
301
329
  // Содержимое — МАССИВ блоков, а не строка: ядро перебирает его как список
@@ -309,6 +337,7 @@ export async function runTask({
309
337
  sessionId: taken.sessionId,
310
338
  provider,
311
339
  model,
340
+ ...(permission ? { permission } : {}),
312
341
  ...(taken.worktree ? { worktree: taken.worktree } : {}),
313
342
  ...(taken.branch ? { branch: taken.branch } : {}),
314
343
  ...(taken.workspaceId ? { workspaceId: taken.workspaceId } : {}),
@@ -359,7 +388,20 @@ export async function runTask({
359
388
  /** Текст сообщения: человеческий, если он есть, иначе встроенная заготовка. */
360
389
  function messageFor(task, config, text) {
361
390
  const own = typeof text === 'string' ? text.trim() : ''
362
- return own !== '' ? own : buildStartMessage(task, config)
391
+ const base = own !== '' ? own : buildStartMessage(task, config)
392
+ if (config?.progressDumpEnabled && task.progressDump) {
393
+ let parsed
394
+ try {
395
+ parsed = typeof task.progressDump === 'string' && task.progressDump.startsWith('{')
396
+ ? JSON.parse(task.progressDump)
397
+ : parseProgressDump(task.progressDump)?.dump
398
+ } catch {
399
+ parsed = parseProgressDump(task.progressDump)?.dump
400
+ }
401
+ const preamble = formatHandoverPreamble(parsed)
402
+ if (preamble) return `${preamble}${base}`
403
+ }
404
+ return base
363
405
  }
364
406
 
365
407
  /**
package/lib/lifecycle.js CHANGED
@@ -9,6 +9,7 @@
9
9
  // делать с очередным событием.
10
10
 
11
11
  import { waitingFromEvent } from './transitions.js'
12
+ import { parseProgressDump, formatProgressDump } from './progress-dump.js'
12
13
 
13
14
  /**
14
15
  * Обработать событие сессии.
@@ -17,14 +18,42 @@ import { waitingFromEvent } from './transitions.js'
17
18
  * большинство — для них здесь ничего не происходит, и это штатный ход, а не
18
19
  * пропуск.
19
20
  *
20
- * @returns {{taskId: string, waiting: boolean}|undefined} что изменилось
21
+ * @returns {{taskId: string, waiting?: boolean}|undefined} что изменилось
21
22
  */
22
- export function handleSessionEvent({ store, sessionId, type }) {
23
- const next = waitingFromEvent(type)
24
- if (next === undefined) return undefined
25
-
23
+ export function handleSessionEvent({ store, sessionId, type, text, error }) {
26
24
  const task = store.findTaskBySession(sessionId)
27
25
  if (task === undefined) return undefined
26
+
27
+ // Если в ответе пришёл PROGRESSDUMP — обновляем срез в задаче
28
+ if (typeof text === 'string' && text.includes('<<<PROGRESSDUMP')) {
29
+ const parsed = parseProgressDump(text)
30
+ if (parsed.ok) {
31
+ store.updateTask(task.id, { progressDump: formatProgressDump(parsed.dump) })
32
+ }
33
+ }
34
+
35
+ // Обновляем исход запуска в истории runs
36
+ if (type === 'turn/end' || type === 'turn/completed') {
37
+ const latestRun = Array.isArray(task.runs) ? task.runs[0] : undefined
38
+ if (latestRun && latestRun.sessionId === sessionId && !latestRun.endedAt && typeof store.updateTaskRun === 'function') {
39
+ store.updateTaskRun(task.id, latestRun.id, {
40
+ endedAt: Date.now(),
41
+ result: 'succeeded',
42
+ })
43
+ }
44
+ } else if (type === 'turn/error' || type === 'session/error') {
45
+ const latestRun = Array.isArray(task.runs) ? task.runs[0] : undefined
46
+ if (latestRun && latestRun.sessionId === sessionId && !latestRun.endedAt && typeof store.updateTaskRun === 'function') {
47
+ store.updateTaskRun(task.id, latestRun.id, {
48
+ endedAt: Date.now(),
49
+ result: 'failed',
50
+ error: typeof error === 'string' ? error : error?.message || 'error',
51
+ })
52
+ }
53
+ }
54
+
55
+ const next = waitingFromEvent(type)
56
+ if (next === undefined) return undefined
28
57
  if (task.waiting === next) return undefined
29
58
 
30
59
  store.updateTask(task.id, { waiting: next })
package/lib/power.js ADDED
@@ -0,0 +1,157 @@
1
+ // dsh-kanban: кроссплатформенный ингибитор сна системы во время работы задач.
2
+ // Поддерживает Windows (PowerShell + Win32 API), Linux (systemd-inhibit) и macOS (caffeinate).
3
+
4
+ import { spawn as defaultSpawn } from 'node:child_process'
5
+ import { existsSync } from 'node:fs'
6
+
7
+ const WINDOWS_HELPER = [
8
+ '$source = @\'',
9
+ 'using System;',
10
+ 'using System.Runtime.InteropServices;',
11
+ 'public static class DshExecutionState {',
12
+ ' [DllImport("kernel32.dll", SetLastError = true)]',
13
+ ' public static extern uint SetThreadExecutionState(uint flags);',
14
+ '}',
15
+ '\'@',
16
+ 'Add-Type -TypeDefinition $source',
17
+ "$continuous = [Convert]::ToUInt32('80000000', 16)",
18
+ '$systemRequired = [uint32]0x00000001',
19
+ 'try {',
20
+ ' $result = [DshExecutionState]::SetThreadExecutionState($continuous -bor $systemRequired)',
21
+ ' if ($result -eq 0) { throw \'SetThreadExecutionState failed\' }',
22
+ ' [Console]::Out.WriteLine(\'READY\')',
23
+ ' [Console]::Out.Flush()',
24
+ ' while ([Console]::In.ReadLine() -ne $null) { }',
25
+ '} finally {',
26
+ ' [void][DshExecutionState]::SetThreadExecutionState($continuous)',
27
+ '}',
28
+ ].join('\n')
29
+
30
+ const LINUX_HELPER = "process.stdout.write('READY\\n'); process.stdin.resume();"
31
+
32
+ const LINUX_INHIBIT_PATHS = ['/usr/bin/systemd-inhibit', '/bin/systemd-inhibit']
33
+
34
+ export class PowerInhibitor {
35
+ constructor(options = {}) {
36
+ this.platform = options.platform ?? process.platform
37
+ this.pid = options.pid ?? process.pid
38
+ this.spawn = options.spawn ?? defaultSpawn
39
+ this.exists = options.exists ?? existsSync
40
+ this.enabled = false
41
+ this.reasons = 0
42
+ this.child = null
43
+ this.phase = 'disabled'
44
+ this.lastError = null
45
+ }
46
+
47
+ setEnabled(enabled) {
48
+ this.enabled = Boolean(enabled)
49
+ this.sync()
50
+ }
51
+
52
+ setReasons(count) {
53
+ this.reasons = Math.max(0, Number(count) || 0)
54
+ this.sync()
55
+ }
56
+
57
+ acquire() {
58
+ this.reasons++
59
+ this.sync()
60
+ }
61
+
62
+ release() {
63
+ this.reasons = Math.max(0, this.reasons - 1)
64
+ this.sync()
65
+ }
66
+
67
+ sync() {
68
+ const shouldHold = this.enabled && this.reasons > 0
69
+ if (shouldHold && !this.child) {
70
+ this.startHelper()
71
+ } else if (!shouldHold && this.child) {
72
+ this.stopHelper()
73
+ }
74
+ }
75
+
76
+ startHelper() {
77
+ this.stopHelper()
78
+ try {
79
+ if (this.platform === 'win32') {
80
+ const psPath = `${process.env.SystemRoot || 'C:\\Windows'}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`
81
+ this.child = this.spawn(psPath, ['-NoProfile', '-NonInteractive', '-Command', WINDOWS_HELPER], {
82
+ shell: false,
83
+ windowsHide: true,
84
+ stdio: ['pipe', 'pipe', 'pipe'],
85
+ })
86
+ } else if (this.platform === 'darwin') {
87
+ this.child = this.spawn('/usr/bin/caffeinate', ['-i', '-w', String(this.pid)], {
88
+ shell: false,
89
+ windowsHide: true,
90
+ stdio: ['ignore', 'ignore', 'ignore'],
91
+ })
92
+ } else if (this.platform === 'linux') {
93
+ const bin = LINUX_INHIBIT_PATHS.find((p) => this.exists(p))
94
+ if (!bin) {
95
+ this.phase = 'unsupported'
96
+ return
97
+ }
98
+ this.child = this.spawn(
99
+ bin,
100
+ ['--what=idle', '--who=dsh-kanban', '--why=Task running', 'node', '-e', LINUX_HELPER],
101
+ { shell: false, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] },
102
+ )
103
+ } else {
104
+ this.phase = 'unsupported'
105
+ return
106
+ }
107
+
108
+ this.phase = 'running'
109
+ this.lastError = null
110
+
111
+ this.child.on('error', (err) => {
112
+ this.lastError = err?.message
113
+ this.phase = 'error'
114
+ this.child = null
115
+ })
116
+
117
+ this.child.on('exit', () => {
118
+ this.child = null
119
+ if (this.enabled && this.reasons > 0) {
120
+ this.phase = 'disabled'
121
+ }
122
+ })
123
+ } catch (err) {
124
+ this.lastError = err?.message
125
+ this.phase = 'error'
126
+ this.child = null
127
+ }
128
+ }
129
+
130
+ stopHelper() {
131
+ if (this.child) {
132
+ try {
133
+ if (this.child.stdin && !this.child.stdin.destroyed) {
134
+ this.child.stdin.end()
135
+ }
136
+ this.child.kill()
137
+ } catch {}
138
+ this.child = null
139
+ }
140
+ this.phase = 'disabled'
141
+ }
142
+
143
+ snapshot() {
144
+ return {
145
+ enabled: this.enabled,
146
+ reasons: this.reasons,
147
+ phase: this.phase,
148
+ lastError: this.lastError,
149
+ }
150
+ }
151
+
152
+ destroy() {
153
+ this.enabled = false
154
+ this.reasons = 0
155
+ this.stopHelper()
156
+ }
157
+ }
@@ -0,0 +1,134 @@
1
+ // dsh-kanban: фиксация и безопасная передача среза прогресса задачи (PROGRESSDUMP).
2
+ // Парсит блок <<<PROGRESSDUMP ... >>>PROGRESSDUMP, маскирует секреты и генерирует вводный бриф.
3
+
4
+ export const BEGIN_MARKER = '<<<PROGRESSDUMP'
5
+ export const END_MARKER = '>>>PROGRESSDUMP'
6
+ export const FIELD_BYTE_LIMIT = 8 * 1024
7
+ export const REDACTED = '[REDACTED]'
8
+
9
+ const SENSITIVE_PATTERNS = [
10
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
11
+ /Bearer\s+[A-Za-z0-9._~+\/=:-]{8,}/gi,
12
+ /sk-[A-Za-z0-9_-]{8,}/g,
13
+ /ghp_[A-Za-z0-9]{20,}/g,
14
+ /glpat-[A-Za-z0-9_-]{15,}/g,
15
+ /xox[bpars]-[A-Za-z0-9-]{10,}/g,
16
+ ]
17
+
18
+ function sanitizeText(raw) {
19
+ let text = raw
20
+ let redacted = false
21
+ for (const pattern of SENSITIVE_PATTERNS) {
22
+ if (pattern.test(text)) {
23
+ redacted = true
24
+ text = text.replace(pattern, REDACTED)
25
+ }
26
+ }
27
+ // Блокируем внедрение слэш-команд харнесса
28
+ text = text.split('\n')
29
+ .map((line) => (line.trim().startsWith('/') ? ('# ' + line) : line))
30
+ .join('\n')
31
+
32
+ return { text, redacted }
33
+ }
34
+
35
+ function truncateBytes(str, maxBytes) {
36
+ const buf = Buffer.from(str, 'utf8')
37
+ if (buf.length <= maxBytes) return str
38
+ return buf.subarray(0, maxBytes).toString('utf8')
39
+ }
40
+
41
+ /**
42
+ * Разобрать блок PROGRESSDUMP из произвольного текста.
43
+ * @param {string} text
44
+ * @returns {{ ok: true, dump: { goal: string, progress: string, next: string, redacted: boolean } } | { ok: false, error: string }}
45
+ */
46
+ export function parseProgressDump(text) {
47
+ if (typeof text !== 'string') return { ok: false, error: 'text-required' }
48
+
49
+ const startIdx = text.indexOf(BEGIN_MARKER)
50
+ const endIdx = text.indexOf(END_MARKER)
51
+ if (startIdx === -1 || endIdx === -1 || endIdx <= startIdx) {
52
+ return { ok: false, error: 'markers-not-found' }
53
+ }
54
+
55
+ const inner = text.slice(startIdx + BEGIN_MARKER.length, endIdx).trim()
56
+ const lines = inner.split('\n')
57
+
58
+ let currentKey = null
59
+ const sections = { goal: [], progress: [], next: [] }
60
+
61
+ for (const rawLine of lines) {
62
+ const line = rawLine.trim()
63
+ const lower = line.toLowerCase()
64
+
65
+ if (lower.startsWith('цель:') || lower.startsWith('goal:')) {
66
+ currentKey = 'goal'
67
+ const val = line.slice(line.indexOf(':') + 1).trim()
68
+ if (val) sections.goal.push(val)
69
+ } else if (lower.startsWith('прогресс:') || lower.startsWith('progress:')) {
70
+ currentKey = 'progress'
71
+ const val = line.slice(line.indexOf(':') + 1).trim()
72
+ if (val) sections.progress.push(val)
73
+ } else if (lower.startsWith('следующие шаги:') || lower.startsWith('next:') || lower.startsWith('шаги:')) {
74
+ currentKey = 'next'
75
+ const val = line.slice(line.indexOf(':') + 1).trim()
76
+ if (val) sections.next.push(val)
77
+ } else if (currentKey) {
78
+ sections[currentKey].push(rawLine)
79
+ }
80
+ }
81
+
82
+ let totalRedacted = false
83
+ const result = { goal: '', progress: '', next: '', redacted: false }
84
+
85
+ for (const key of ['goal', 'progress', 'next']) {
86
+ const rawVal = sections[key].join('\n').trim()
87
+ const { text: cleanText, redacted } = sanitizeText(rawVal)
88
+ if (redacted) totalRedacted = true
89
+ result[key] = truncateBytes(cleanText, FIELD_BYTE_LIMIT)
90
+ }
91
+
92
+ result.redacted = totalRedacted
93
+ return { ok: true, dump: result }
94
+ }
95
+
96
+ /**
97
+ * Сериализовать срез в канонический блок <<<PROGRESSDUMP ... >>>PROGRESSDUMP.
98
+ */
99
+ export function formatProgressDump(dump) {
100
+ if (!dump) return ''
101
+ return [
102
+ BEGIN_MARKER,
103
+ 'Цель:',
104
+ dump.goal || '—',
105
+ '',
106
+ 'Прогресс:',
107
+ dump.progress || '—',
108
+ '',
109
+ 'Следующие шаги:',
110
+ dump.next || '—',
111
+ END_MARKER,
112
+ ].join('\n')
113
+ }
114
+
115
+ /**
116
+ * Сформировать аккуратный вводный бриф для новой сессии агента.
117
+ */
118
+ export function formatHandoverPreamble(dump) {
119
+ if (!dump || (!dump.goal && !dump.progress && !dump.next)) return ''
120
+ return [
121
+ '## 📋 Эстафета задачи (PROGRESSDUMP)',
122
+ '> [!NOTE]',
123
+ '> Предыдущий ход зафиксировал текущее состояние работы:',
124
+ '',
125
+ '**Цель:** ' + (dump.goal || '—'),
126
+ '',
127
+ '**Сделано:** ' + (dump.progress || '—'),
128
+ '',
129
+ '**Следующие шаги:** ' + (dump.next || '—'),
130
+ '',
131
+ '---',
132
+ '',
133
+ ].join('\n')
134
+ }
package/lib/routes.js CHANGED
@@ -14,6 +14,7 @@ import { columnStats, doneWithin, stale } from './metrics.js'
14
14
  import { isStale } from './time.js'
15
15
  import { facetsOf, facetsOfTask, groupByRepo, sortByLife } from './filters.js'
16
16
  import { parseBody } from './markdown.js'
17
+ import { computeNextRun } from './cron.js'
17
18
 
18
19
  /** Больше этого тело запроса не читаем: без предела один запрос кладёт хост. */
19
20
  export const MAX_BODY_BYTES = 256 * 1024
@@ -183,7 +184,8 @@ export function createTask({ store, input }) {
183
184
 
184
185
  /** Правка полей карточки. Колонку и порядок правит только перенос. */
185
186
  export function updateTask({ store, id, input }) {
186
- if (store.getTask(id) === undefined) return { error: 'task-not-found', status: 404 }
187
+ const current = store.getTask(id)
188
+ if (current === undefined) return { error: 'task-not-found', status: 404 }
187
189
  const patch = {}
188
190
  if (typeof input?.title === 'string' && input.title.trim() !== '') patch.title = input.title.trim()
189
191
  if (typeof input?.body === 'string') patch.body = input.body
@@ -197,9 +199,28 @@ export function updateTask({ store, id, input }) {
197
199
  if (typeof input?.dueAt === 'number' && Number.isFinite(input.dueAt) && input.dueAt >= 0) {
198
200
  patch.dueAt = Math.floor(input.dueAt)
199
201
  }
202
+ if (typeof input?.cron === 'string') {
203
+ patch.cron = input.cron.trim()
204
+ patch.nextRunAt = patch.cron ? (computeNextRun(patch.cron) || 0) : 0
205
+ }
206
+ if (typeof input?.progressDump === 'string') {
207
+ patch.progressDump = input.progressDump
208
+ }
209
+ if (typeof input?.permission === 'string') {
210
+ patch.permission = input.permission
211
+ if (input.permission !== current.permission) {
212
+ patch.permissionConfirmedAt = 0
213
+ }
214
+ }
200
215
  return { task: store.updateTask(id, patch) }
201
216
  }
202
217
 
218
+ export function confirmPermission({ store, id }) {
219
+ const task = store.getTask(id)
220
+ if (task === undefined) return { error: 'task-not-found', status: 404 }
221
+ return { task: store.updateTask(id, { permissionConfirmedAt: Date.now() }) }
222
+ }
223
+
203
224
  /** Известные приоритеты. Пустой — «без приоритета». */
204
225
  export const PRIORITIES = ['', 'high', 'medium', 'low']
205
226
 
package/lib/store.js CHANGED
@@ -40,7 +40,14 @@ CREATE TABLE IF NOT EXISTS tasks (
40
40
  labels TEXT NOT NULL DEFAULT '[]',
41
41
  branch TEXT,
42
42
  worktree TEXT,
43
- workspaceId TEXT NOT NULL DEFAULT '',
43
+ workspaceId TEXT NOT NULL DEFAULT '',
44
+ cron TEXT NOT NULL DEFAULT '',
45
+ nextRunAt INTEGER NOT NULL DEFAULT 0,
46
+ lastRunAt INTEGER NOT NULL DEFAULT 0,
47
+ progressDump TEXT NOT NULL DEFAULT '',
48
+ permission TEXT NOT NULL DEFAULT '',
49
+ permissionConfirmedAt INTEGER NOT NULL DEFAULT 0,
50
+ runs TEXT NOT NULL DEFAULT '[]',
44
51
  sessionId TEXT,
45
52
  model TEXT,
46
53
  provider TEXT,
@@ -85,6 +92,7 @@ const WRITABLE = [
85
92
  'title', 'body', 'owner', 'repo', 'issueNumber', 'issueUrl', 'labels',
86
93
  'branch', 'worktree', 'workspaceId', 'sessionId', 'model', 'provider', 'waiting', 'syncedAt', 'plan',
87
94
  'labelColors', 'queuedAt', 'createdAt', 'author', 'assignee', 'milestone', 'priority', 'dueAt',
95
+ 'cron', 'nextRunAt', 'lastRunAt', 'progressDump', 'permission', 'permissionConfirmedAt', 'runs',
88
96
  ]
89
97
 
90
98
  /** Источники перехода; чужое значение не пишем, чтобы журнал оставался читаемым. */
@@ -92,7 +100,7 @@ const SOURCES = new Set(['manual', 'session', 'gitea', 'tool'])
92
100
 
93
101
  function rowToTask(row) {
94
102
  if (row === undefined) return undefined
95
- const { col, labels, waiting, labelColors, ...rest } = row
103
+ const { col, labels, waiting, labelColors, runs, ...rest } = row
96
104
  let parsed = []
97
105
  try {
98
106
  const value = JSON.parse(labels)
@@ -103,7 +111,12 @@ function rowToTask(row) {
103
111
  const value = JSON.parse(labelColors ?? '{}')
104
112
  if (value !== null && typeof value === 'object' && !Array.isArray(value)) colors = value
105
113
  } catch { /* испорченное поле не должно ронять чтение всей доски */ }
106
- return { ...rest, column: col, labels: parsed, labelColors: colors, waiting: waiting === 1 }
114
+ let parsedRuns = []
115
+ try {
116
+ const value = JSON.parse(runs ?? '[]')
117
+ if (Array.isArray(value)) parsedRuns = value
118
+ } catch { /* испорченное поле не должно ронять чтение всей доски */ }
119
+ return { ...rest, column: col, labels: parsed, labelColors: colors, waiting: waiting === 1, runs: parsedRuns }
107
120
  }
108
121
 
109
122
  /**
@@ -134,6 +147,13 @@ export function openStore({ dir }) {
134
147
  ['priority', "TEXT NOT NULL DEFAULT ''"],
135
148
  ['dueAt', 'INTEGER NOT NULL DEFAULT 0'],
136
149
  ['workspaceId', "TEXT NOT NULL DEFAULT ''"],
150
+ ['cron', "TEXT NOT NULL DEFAULT ''"],
151
+ ['nextRunAt', 'INTEGER NOT NULL DEFAULT 0'],
152
+ ['lastRunAt', 'INTEGER NOT NULL DEFAULT 0'],
153
+ ['progressDump', "TEXT NOT NULL DEFAULT ''"],
154
+ ['permission', "TEXT NOT NULL DEFAULT ''"],
155
+ ['permissionConfirmedAt', 'INTEGER NOT NULL DEFAULT 0'],
156
+ ['runs', "TEXT NOT NULL DEFAULT '[]'"],
137
157
  ]) {
138
158
  try { db.exec(`ALTER TABLE tasks ADD COLUMN ${column} ${definition}`) } catch { /* столбец уже есть */ }
139
159
  }
@@ -165,6 +185,8 @@ export function openStore({ dir }) {
165
185
  countInColumn: db.prepare('SELECT COUNT(*) AS n FROM tasks WHERE board = ? AND col = ?'),
166
186
  watchable: db.prepare(
167
187
  "SELECT * FROM tasks WHERE issueNumber IS NOT NULL AND col NOT IN ('done') ORDER BY updatedAt DESC"),
188
+ scheduledDue: db.prepare(
189
+ "SELECT * FROM tasks WHERE cron != '' AND nextRunAt > 0 AND nextRunAt <= ? AND archivedAt = 0 ORDER BY nextRunAt ASC"),
168
190
  }
169
191
 
170
192
  function ensureBoard(id, title, kind) {
@@ -263,8 +285,9 @@ export function openStore({ dir }) {
263
285
  db.prepare(`INSERT INTO tasks
264
286
  (id, board, col, position, title, body, owner, repo, issueNumber, issueUrl,
265
287
  labels, branch, worktree, workspaceId, sessionId, model, provider, waiting, syncedAt,
266
- labelColors, author, assignee, milestone, priority, dueAt, columnAt, createdAt, updatedAt)
267
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
288
+ labelColors, author, assignee, milestone, priority, dueAt, columnAt, createdAt, updatedAt,
289
+ cron, nextRunAt, lastRunAt, progressDump, permission, permissionConfirmedAt, runs)
290
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
268
291
  id, board, column, position,
269
292
  input.title ?? '', input.body ?? '',
270
293
  input.owner ?? null, input.repo ?? null,
@@ -281,6 +304,13 @@ export function openStore({ dir }) {
281
304
  input.priority ?? '',
282
305
  Number(input.dueAt) || 0,
283
306
  now, born, now,
307
+ input.cron ?? '',
308
+ Number(input.nextRunAt) || 0,
309
+ Number(input.lastRunAt) || 0,
310
+ input.progressDump ?? '',
311
+ input.permission ?? '',
312
+ Number(input.permissionConfirmedAt) || 0,
313
+ typeof input.runs === 'string' ? input.runs : JSON.stringify(input.runs ?? []),
284
314
  )
285
315
  return rowToTask(q.taskById.get(id))
286
316
  },
@@ -295,6 +325,7 @@ export function openStore({ dir }) {
295
325
  fields.push(`${key} = ?`)
296
326
  if (key === 'labels') values.push(JSON.stringify(patch[key] ?? []))
297
327
  else if (key === 'labelColors') values.push(JSON.stringify(patch[key] ?? {}))
328
+ else if (key === 'runs') values.push(typeof patch[key] === 'string' ? patch[key] : JSON.stringify(patch[key] ?? []))
298
329
  else if (key === 'waiting') values.push(patch[key] ? 1 : 0)
299
330
  else if (key === 'syncedAt') values.push(Number(patch[key]) || 0)
300
331
  else values.push(patch[key] ?? null)
@@ -306,6 +337,31 @@ export function openStore({ dir }) {
306
337
  return rowToTask(q.taskById.get(id))
307
338
  },
308
339
 
340
+ listDueTasks(now = Date.now()) {
341
+ return q.scheduledDue.all(now).map(rowToTask)
342
+ },
343
+
344
+ recordTaskRun(id, run) {
345
+ const task = rowToTask(q.taskById.get(id))
346
+ if (!task) return undefined
347
+ const runs = Array.isArray(task.runs) ? [...task.runs] : []
348
+ runs.unshift(run)
349
+ const trimmed = runs.slice(0, 20)
350
+ this.updateTask(id, { runs: JSON.stringify(trimmed) })
351
+ return rowToTask(q.taskById.get(id))
352
+ },
353
+
354
+ updateTaskRun(id, runId, patch) {
355
+ const task = rowToTask(q.taskById.get(id))
356
+ if (!task) return undefined
357
+ const runs = Array.isArray(task.runs) ? [...task.runs] : []
358
+ const idx = runs.findIndex((r) => r.id === runId)
359
+ if (idx === -1) return undefined
360
+ runs[idx] = { ...runs[idx], ...patch }
361
+ this.updateTask(id, { runs: JSON.stringify(runs) })
362
+ return rowToTask(q.taskById.get(id))
363
+ },
364
+
309
365
  moveTask(id, { column, beforeId, afterId }) {
310
366
  const current = q.taskById.get(id)
311
367
  if (current === undefined) throw new Error(`задача ${id} не найдена`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-kanban",
3
- "version": "0.1.25",
3
+ "version": "0.1.27",
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",
@@ -45,10 +45,7 @@
45
45
  },
46
46
  "client": {
47
47
  "platform": "web",
48
- "inject": [
49
- "@deepseek-ai/dsh-client-runtime",
50
- "@deepseek-ai/dsh-client-ui-slots"
51
- ]
48
+ "inject": []
52
49
  }
53
50
  },
54
51
  "peerDependencies": {