@goodandready/dsh-kanban 0.2.31 → 0.2.32

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/board-tool.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { verifyAndRecordGate } from './verification.js'
1
2
  //
2
3
  // JSON Schema:
3
4
  //
@@ -111,6 +112,20 @@ export function boardMoveDefinition({ store, config, eventHub }) {
111
112
  + `Pending: ${pendingRequired.map((i) => i.text).join('; ')}. `
112
113
  + 'Use `board_checklist` with verification evidence to complete required items.'
113
114
  }
115
+
116
+ // Automated Verification Gate (#372)
117
+ if (task.column !== 'review') {
118
+ const gateResult = await verifyAndRecordGate({
119
+ task,
120
+ config,
121
+ store,
122
+ agents: exec?.agents || exec?.agent?.parent,
123
+ createMessage: exec?.createMessage,
124
+ })
125
+ if (!gateResult.allowed) {
126
+ return `Cannot move card to review: verification command "${gateResult.verification.command}" failed with exit code ${gateResult.verification.exitCode}.\nErrors:\n${gateResult.verification.logSnippet}`
127
+ }
128
+ }
114
129
  }
115
130
  const move = resolveTransition(task.column, [{ column, source: 'tool', detail: detail ?? '' }])
116
131
  if (move === undefined) return `The card is already in ${task.column}.`
package/lib/client.js CHANGED
@@ -932,6 +932,13 @@ window.__ModuleLoader__.load({
932
932
  'field.workspaceClaimBoundaries': 'Workspace claim boundaries',
933
933
  'field.autoSyncSessions': 'Auto-sync external chat sessions',
934
934
  'hint.autoSyncSessions': 'Automatically create board cards for standard DSH chat conversations.',
935
+ 'field.verificationGateEnabled': 'Verification gate',
936
+ 'hint.verificationGateEnabled': 'Run automated verification gate (e.g. npm test) before allowing cards to move to Review.',
937
+ 'field.verificationCommand': 'Verification command',
938
+ 'hint.verificationCommand': 'Default verification command to execute in task worktree before Review (default: npm test).',
939
+ 'field.verificationTimeoutMs': 'Verification timeout (ms)',
940
+ 'hint.verificationTimeoutMs': 'Timeout in milliseconds for verification gate execution (default: 120000).',
941
+ 'panel.verificationFailed': 'Automated verification before Review failed',
935
942
  'hint.announceToAgent': 'Injects guidance section into the agent system prompt.',
936
943
  'task.runs': 'Execution History',
937
944
  'task.noRuns': 'No runs yet',
@@ -1215,6 +1222,8 @@ window.__ModuleLoader__.load({
1215
1222
  'diff.truncated': 'Diff truncated (exceeded limit)',
1216
1223
  'diff.show': 'Show diff',
1217
1224
  'diff.hide': 'Hide diff',
1225
+ 'diff.discard': 'Discard',
1226
+ 'diff.confirmDiscard': 'Discard changes to {file}?',
1218
1227
  'acceptance.acceptAndMerge': 'Accept & Merge (--no-ff)',
1219
1228
  'acceptance.merging': 'Merging...',
1220
1229
  'acceptance.title': 'Review Acceptance Gate',
@@ -1324,6 +1333,13 @@ window.__ModuleLoader__.load({
1324
1333
  'field.workspaceClaimBoundaries': '工作区认领边界',
1325
1334
  'field.autoSyncSessions': '自动同步外部聊天会话',
1326
1335
  'hint.autoSyncSessions': '自动将 DSH 的标准聊天会话同步为看板任务卡片。',
1336
+ 'field.verificationGateEnabled': '验证网关',
1337
+ 'hint.verificationGateEnabled': '在允许卡片移至评审前运行自动验证 (如 npm test)。',
1338
+ 'field.verificationCommand': '验证命令',
1339
+ 'hint.verificationCommand': '在工作区运行的默认验证命令 (默认: npm test)。',
1340
+ 'field.verificationTimeoutMs': '验证超时 (毫秒)',
1341
+ 'hint.verificationTimeoutMs': '验证执行的超时时间 (毫秒,默认 120000)。',
1342
+ 'panel.verificationFailed': '移至评审前的自动验证失败',
1327
1343
  'hint.announceToAgent': '向 Agent 系统提示词注入看板与进度协作说明。',
1328
1344
  'task.runs': '执行历史',
1329
1345
  'task.noRuns': '暂无执行记录',
@@ -1607,6 +1623,8 @@ window.__ModuleLoader__.load({
1607
1623
  'diff.truncated': 'Diff 超限已截断',
1608
1624
  'diff.show': '查看 diff',
1609
1625
  'diff.hide': '收起 diff',
1626
+ 'diff.discard': '放弃修改',
1627
+ 'diff.confirmDiscard': '确认放弃对 {file} 的修改吗?',
1610
1628
  'acceptance.acceptAndMerge': '验收并合并 (--no-ff)',
1611
1629
  'acceptance.merging': '合并中...',
1612
1630
  'acceptance.title': '评审验收网关',
@@ -1694,6 +1712,9 @@ window.__ModuleLoader__.load({
1694
1712
  { field: 'maxConcurrentSessions', kind: 'number', group: 'limits' },
1695
1713
  { field: 'workspaceClaimBoundaries', kind: 'bool', group: 'general' },
1696
1714
  { field: 'autoSyncSessions', kind: 'bool', group: 'sync' },
1715
+ { field: 'verificationGateEnabled', kind: 'bool', group: 'general' },
1716
+ { field: 'verificationCommand', kind: 'text', group: 'general' },
1717
+ { field: 'verificationTimeoutMs', kind: 'number', group: 'general' },
1697
1718
  ]
1698
1719
 
1699
1720
  function hintKey(spec) {
@@ -2068,6 +2089,11 @@ window.__ModuleLoader__.load({
2068
2089
  Array.isArray(task.checklist) && task.checklist.length > 0
2069
2090
  ? h('span', { className: 'dkb-tag', title: 'DoD Checklist' }, `☑ ${task.checklist.filter((i) => i.completed).length}/${task.checklist.length}`)
2070
2091
  : null,
2092
+ task.report?.verification?.status === 'passed'
2093
+ ? h('span', { className: 'dkb-tag', style: { background: 'var(--dsw-alias-state-success-secondary, #dafbe1)', color: 'var(--dsw-alias-state-success-primary, #1a7f37)', borderColor: 'var(--dsw-alias-state-success-secondary, #4ac26b)' }, title: task.report.verification.command || 'Tests passed' }, '✓ PASS')
2094
+ : (task.report?.verification?.status === 'failed'
2095
+ ? h('span', { className: 'dkb-tag', style: { background: 'var(--dsw-alias-state-error-secondary, #ffebe9)', color: 'var(--dsw-alias-state-error-primary, #cf222e)', borderColor: 'var(--dsw-alias-state-error-secondary, #ffc1c0)' }, title: task.report.verification.command || 'Tests failed' }, '✗ FAIL')
2096
+ : null),
2071
2097
  task.report && task.report.summary
2072
2098
  ? h('span', { className: 'dkb-tag', title: 'Execution Report' }, '📄 report')
2073
2099
  : null,
@@ -2998,6 +3024,29 @@ window.__ModuleLoader__.load({
2998
3024
  t('panel.worktree', { path: openTask.worktree })
2999
3025
  + (openTask.branch ? ' (' + openTask.branch + ')' : ''))
3000
3026
  : null,
3027
+ openTask.report?.verification?.status === 'failed'
3028
+ ? h('div', {
3029
+ style: {
3030
+ margin: '8px 0', padding: '10px 12px',
3031
+ background: 'var(--dsw-alias-state-error-secondary, #ffebe9)',
3032
+ border: '1px solid var(--dsw-alias-state-error-primary, #cf222e)',
3033
+ borderRadius: 8, fontSize: 12, display: 'flex', flexDirection: 'column', gap: 6,
3034
+ },
3035
+ },
3036
+ h('div', { style: { fontWeight: 'bold', color: 'var(--dsw-alias-state-error-primary, #cf222e)' } },
3037
+ '✗ ' + (t('panel.verificationFailed') || 'Automated verification before Review failed') + (openTask.report.verification.command ? ' (' + openTask.report.verification.command + ')' : '')
3038
+ ),
3039
+ openTask.report.verification.log
3040
+ ? h('pre', {
3041
+ style: {
3042
+ margin: 0, padding: 6, maxHeight: 150, overflowY: 'auto',
3043
+ background: 'var(--dsw-alias-bg-layer-2)',
3044
+ fontSize: 11, fontFamily: 'monospace', borderRadius: 4, whiteSpace: 'pre-wrap',
3045
+ },
3046
+ }, openTask.report.verification.log)
3047
+ : null
3048
+ )
3049
+ : null,
3001
3050
  openTask.permission && !openTask.permissionConfirmedAt
3002
3051
  ? h('div', {
3003
3052
  className: 'dkb-warnBox',
@@ -3357,17 +3406,74 @@ window.__ModuleLoader__.load({
3357
3406
  : null,
3358
3407
  diffLoading
3359
3408
  ? h('div', { style: { fontSize: 12, opacity: 0.7 } }, 'Loading diff...')
3360
- : diffData?.diff
3361
- ? h('pre', {
3362
- style: {
3363
- margin: 0, padding: 8, maxHeight: 280, overflowY: 'auto',
3364
- background: 'var(--dsw-alias-bg-layer-2)',
3365
- color: 'var(--dsw-alias-label-primary)',
3366
- fontSize: 11, fontFamily: 'monospace', borderRadius: 4, whiteSpace: 'pre-wrap',
3367
- border: '1px solid var(--dsw-alias-border-l1)',
3368
- },
3369
- }, diffData.diff)
3370
- : h('div', { style: { fontSize: 12, opacity: 0.7 } }, t('diff.empty'))
3409
+ : h('div', { style: { display: 'flex', flexDirection: 'column', gap: 6 } },
3410
+ diffData?.files && diffData.files.length > 0
3411
+ ? h('div', { style: { display: 'flex', flexDirection: 'column', gap: 4, margin: '2px 0' } },
3412
+ diffData.files.map((f) => h('div', {
3413
+ key: f.path,
3414
+ style: {
3415
+ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11,
3416
+ padding: '3px 6px', background: 'var(--dsw-alias-bg-layer-2)',
3417
+ borderRadius: 4, border: '1px solid var(--dsw-alias-border-l1)',
3418
+ },
3419
+ },
3420
+ h('span', {
3421
+ style: {
3422
+ fontWeight: 'bold', fontSize: 10, padding: '1px 4px', borderRadius: 3,
3423
+ background: f.status === 'added' ? 'var(--dsw-alias-state-success-secondary, #dafbe1)'
3424
+ : (f.status === 'deleted' ? 'var(--dsw-alias-state-error-secondary, #ffebe9)'
3425
+ : 'var(--dsw-alias-state-warn-secondary, #fff8c5)'),
3426
+ color: f.status === 'added' ? 'var(--dsw-alias-state-success-primary, #1a7f37)'
3427
+ : (f.status === 'deleted' ? 'var(--dsw-alias-state-error-primary, #cf222e)'
3428
+ : 'var(--dsw-alias-state-warn-primary, #9a6700)'),
3429
+ },
3430
+ }, f.status === 'added' ? 'A' : (f.status === 'deleted' ? 'D' : (f.status === 'untracked' ? '?' : 'M'))),
3431
+ h('span', { style: { flex: 1, fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis' } }, f.path),
3432
+ (f.insertions || f.deletions)
3433
+ ? h('span', { style: { fontSize: 10, fontFamily: 'monospace', opacity: 0.85 } },
3434
+ f.insertions ? h('span', { style: { color: 'var(--dsw-alias-state-success-primary, #1a7f37)', marginRight: 4 } }, `+${f.insertions}`) : null,
3435
+ f.deletions ? h('span', { style: { color: 'var(--dsw-alias-state-error-primary, #cf222e)' } }, `-${f.deletions}`) : null,
3436
+ )
3437
+ : null,
3438
+ h('button', {
3439
+ type: 'button',
3440
+ className: 'dkb-discard',
3441
+ style: { padding: '1px 6px', fontSize: 10, cursor: 'pointer' },
3442
+ title: t('diff.discard') || 'Discard',
3443
+ onClick: async (e) => {
3444
+ e.stopPropagation()
3445
+ const confirmMsg = t('diff.confirmDiscard', { file: f.path }) || `Discard changes to ${f.path}?`
3446
+ if (window.confirm(confirmMsg)) {
3447
+ try {
3448
+ await api('/task/' + encodeURIComponent(openTask.id) + '/discard', {
3449
+ method: 'POST',
3450
+ body: JSON.stringify({ file: f.path }),
3451
+ })
3452
+ await loadDiffAndCommits()
3453
+ await load(board)
3454
+ } catch (err) {
3455
+ setError(t(err.key || err.error || 'error.unknown'))
3456
+ }
3457
+ }
3458
+ },
3459
+ }, t('diff.discard') || 'Discard')
3460
+ ))
3461
+ )
3462
+ : null,
3463
+ diffData?.diff
3464
+ ? h('pre', {
3465
+ style: {
3466
+ margin: 0, padding: 8, maxHeight: 280, overflowY: 'auto',
3467
+ background: 'var(--dsw-alias-bg-layer-2)',
3468
+ color: 'var(--dsw-alias-label-primary)',
3469
+ fontSize: 11, fontFamily: 'monospace', borderRadius: 4, whiteSpace: 'pre-wrap',
3470
+ border: '1px solid var(--dsw-alias-border-l1)',
3471
+ },
3472
+ }, diffData.diff)
3473
+ : (!diffData?.files || diffData.files.length === 0
3474
+ ? h('div', { style: { fontSize: 12, opacity: 0.7 } }, t('diff.empty'))
3475
+ : null)
3476
+ )
3371
3477
  ) : null
3372
3478
  )
3373
3479
  : null,
package/lib/config.js CHANGED
@@ -76,6 +76,9 @@ export const CONFIG_DEFAULTS = {
76
76
  maxConcurrentSessions: 3,
77
77
  workspaceClaimBoundaries: true,
78
78
  autoSyncSessions: false,
79
+ verificationGateEnabled: true,
80
+ verificationCommand: 'npm test',
81
+ verificationTimeoutMs: 120000,
79
82
  }
80
83
 
81
84
  /** Field descriptions reused in schema and settings card. */
@@ -111,6 +114,9 @@ export const CONFIG_HINTS = {
111
114
  maxConcurrentSessions: 'Maximum number of simultaneous active agent sessions on the board.',
112
115
  workspaceClaimBoundaries: 'Enforce workspace ownership boundaries to prevent cross-workspace task execution.',
113
116
  autoSyncSessions: 'Automatically sync and attach live background agent sessions to cards.',
117
+ verificationGateEnabled: 'Run automated verification gate (e.g. npm test) before allowing cards to move to Review.',
118
+ verificationCommand: 'Default verification command to execute in task worktree before Review (default: npm test).',
119
+ verificationTimeoutMs: 'Timeout in milliseconds for verification gate execution (default: 120000).',
114
120
  }
115
121
 
116
122
  /** Setting key for column WIP limit; not all columns have limits. */
package/lib/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { verifyAndRecordGate } from './verification.js'
1
2
  import { registerPluginUpdater } from './updater.js'
2
3
  // dsh-kanban — task board for DeepSeek Harness.
3
4
  //
@@ -34,6 +35,7 @@ import {
34
35
  exportTasksCsv,
35
36
  handleCleanupWorktree,
36
37
  handleTaskDiff,
38
+ handleTaskDiscard,
37
39
  handleTaskCommits,
38
40
  handleTaskMerge,
39
41
  handleTaskWorktreeStatus,
@@ -1151,8 +1153,31 @@ export function apply(ctx, config) {
1151
1153
  try {
1152
1154
  const input = await readBody(req)
1153
1155
  if (req.method === 'POST' && action === 'move') {
1154
- // Manual move dispatches stop command to agent prior to updating card column.
1155
1156
  const was = store.getTask(id)
1157
+ if (was === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
1158
+
1159
+ // Automated Verification Gate (#372): intercept transition to review
1160
+ if (input.column === 'review' && was.column !== 'review') {
1161
+ const gateResult = await verifyAndRecordGate({
1162
+ task: was,
1163
+ config: getConfig(),
1164
+ store,
1165
+ agents: ctx.agents,
1166
+ createMessage: createUserMessage,
1167
+ })
1168
+ if (!gateResult.allowed) {
1169
+ writeJson(res, 422, {
1170
+ error: 'verification-failed',
1171
+ command: gateResult.verification.command,
1172
+ exitCode: gateResult.verification.exitCode,
1173
+ log: gateResult.verification.logSnippet,
1174
+ task: store.getTask(id),
1175
+ })
1176
+ return
1177
+ }
1178
+ }
1179
+
1180
+ // Manual move dispatches stop command to agent prior to updating card column.
1156
1181
  const acted = was !== undefined && input.column !== was.column
1157
1182
  ? dispatchMove({
1158
1183
  agents: ctx.agents, task: was, column: input.column,
@@ -1193,8 +1218,20 @@ export function apply(ctx, config) {
1193
1218
  writeJson(res, resObj.status, resObj.body)
1194
1219
  return
1195
1220
  }
1221
+ if (req.method === 'POST' && action === 'discard') {
1222
+ const resObj = await handleTaskDiscard({
1223
+ task: store.getTask(id),
1224
+ file: input?.file,
1225
+ discardTaskFile,
1226
+ })
1227
+ if (resObj?.body?.success) {
1228
+ eventHub.broadcast('task:change', { taskId: id, action: 'discard', file: input?.file })
1229
+ }
1230
+ writeJson(res, resObj.status, resObj.body)
1231
+ return
1232
+ }
1196
1233
  if (req.method === 'GET' && action === 'diff') {
1197
- const resObj = await handleTaskDiff({ task: store.getTask(id), getTaskDiff })
1234
+ const resObj = await handleTaskDiff({ task: store.getTask(id), getTaskDiff, getTaskDiffInspector })
1198
1235
  writeJson(res, resObj.status, resObj.body)
1199
1236
  return
1200
1237
  }
package/lib/routes.js CHANGED
@@ -773,12 +773,27 @@ export async function handleCleanupWorktree({
773
773
  /**
774
774
  * Get working copy diff for task.
775
775
  */
776
- export async function handleTaskDiff({ task, getTaskDiff }) {
776
+ export async function handleTaskDiff({ task, getTaskDiff, getTaskDiffInspector }) {
777
777
  if (!task) return { status: 404, body: { error: 'task-not-found' } }
778
+ if (typeof getTaskDiffInspector === 'function') {
779
+ const inspectorResult = await getTaskDiffInspector({ worktreePath: task.worktree })
780
+ return { status: 200, body: inspectorResult }
781
+ }
778
782
  const diffResult = await getTaskDiff({ worktreePath: task.worktree })
779
783
  return { status: 200, body: diffResult }
780
784
  }
781
785
 
786
+ /**
787
+ * Discard file changes in task worktree (#371).
788
+ */
789
+ export async function handleTaskDiscard({ task, file, discardTaskFile }) {
790
+ if (!task) return { status: 404, body: { error: 'task-not-found' } }
791
+ if (!file || typeof file !== 'string') return { status: 400, body: { error: 'file-required' } }
792
+ if (!task.worktree) return { status: 400, body: { error: 'no-worktree' } }
793
+ const result = await discardTaskFile({ worktreePath: task.worktree, file })
794
+ return { status: result.success ? 200 : 400, body: result }
795
+ }
796
+
782
797
  /**
783
798
  * Get commit list for task.
784
799
  */
package/lib/store.js CHANGED
@@ -108,6 +108,7 @@ const WRITABLE = [
108
108
  'cron', 'nextRunAt', 'lastRunAt', 'progressDump', 'permission', 'reasoningEffort', 'permissionConfirmedAt',
109
109
  'runs', 'checklist', 'report', 'comments', 'rejectReason', 'rejectedAt', 'parentId', 'isEpic',
110
110
  'workflowRunId', 'workflowStatus', 'permissionConfirmedTier', 'forge', 'queuedRequest',
111
+ 'verificationCommand', 'verificationStatus',
111
112
  ]
112
113
 
113
114
  /** Valid transition sources; unexpected values are rejected to preserve log integrity. */
@@ -249,6 +250,8 @@ export function openStore({ dir }) {
249
250
  ['forge', "TEXT NOT NULL DEFAULT ''"],
250
251
  ['permissionConfirmedTier', "TEXT NOT NULL DEFAULT ''"],
251
252
  ['queuedRequest', "TEXT NOT NULL DEFAULT ''"],
253
+ ['verificationCommand', "TEXT NOT NULL DEFAULT ''"],
254
+ ['verificationStatus', "TEXT NOT NULL DEFAULT ''"],
252
255
  ]) {
253
256
  try { db.exec(`ALTER TABLE tasks ADD COLUMN ${column} ${definition}`) } catch { /* column already exists */ }
254
257
  }
@@ -0,0 +1,242 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { existsSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+
5
+ /**
6
+ * Run verification gate command for task before moving to review (#372).
7
+ *
8
+ * @param {object} options
9
+ * @param {object} options.task
10
+ * @param {object|Function} options.config
11
+ * @param {string} [options.worktreePath]
12
+ * @param {Function} [options.spawnRunner]
13
+ * @returns {Promise<{
14
+ * passed: boolean,
15
+ * skipped?: boolean,
16
+ * skipReason?: string,
17
+ * command: string,
18
+ * exitCode: number,
19
+ * stdout: string,
20
+ * stderr: string,
21
+ * logSnippet: string,
22
+ * durationMs: number
23
+ * }>}
24
+ */
25
+ export async function runVerificationGate({
26
+ task,
27
+ config,
28
+ worktreePath,
29
+ spawnRunner = spawn,
30
+ }) {
31
+ const cfg = typeof config === 'function' ? config() : (config || {})
32
+ const enabled = cfg.verificationGateEnabled !== false
33
+ const customCmd = typeof task?.verificationCommand === 'string' && task.verificationCommand.trim() !== ''
34
+ ? task.verificationCommand.trim()
35
+ : ''
36
+ const defaultCmd = typeof cfg.verificationCommand === 'string' && cfg.verificationCommand.trim() !== ''
37
+ ? cfg.verificationCommand.trim()
38
+ : 'npm test'
39
+ const command = customCmd || defaultCmd
40
+ const timeoutMs = Number(task?.verificationTimeoutMs || cfg.verificationTimeoutMs) || 120000
41
+
42
+ const cwd = worktreePath || task?.worktree
43
+ if (!cwd || !existsSync(cwd)) {
44
+ return {
45
+ passed: true,
46
+ skipped: true,
47
+ skipReason: 'worktree-not-found',
48
+ command,
49
+ exitCode: 0,
50
+ stdout: '',
51
+ stderr: '',
52
+ logSnippet: '',
53
+ durationMs: 0,
54
+ }
55
+ }
56
+
57
+ // If gate is disabled and no task-specific command was configured, skip
58
+ if (!enabled && !customCmd) {
59
+ return {
60
+ passed: true,
61
+ skipped: true,
62
+ skipReason: 'gate-disabled',
63
+ command,
64
+ exitCode: 0,
65
+ stdout: '',
66
+ stderr: '',
67
+ logSnippet: '',
68
+ durationMs: 0,
69
+ }
70
+ }
71
+
72
+ // If using default 'npm test' but no package.json exists in worktree, skip safely
73
+ if (!customCmd && defaultCmd === 'npm test') {
74
+ const pkgPath = join(cwd, 'package.json')
75
+ if (!existsSync(pkgPath)) {
76
+ return {
77
+ passed: true,
78
+ skipped: true,
79
+ skipReason: 'no-package-json',
80
+ command,
81
+ exitCode: 0,
82
+ stdout: '',
83
+ stderr: '',
84
+ logSnippet: '',
85
+ durationMs: 0,
86
+ }
87
+ }
88
+ }
89
+
90
+ const start = Date.now()
91
+ return new Promise((resolve) => {
92
+ let stdout = ''
93
+ let stderr = ''
94
+ let timedOut = false
95
+ const MAX_OUTPUT = 64 * 1024
96
+
97
+ const proc = spawnRunner('bash', ['-lc', command], {
98
+ cwd,
99
+ timeout: timeoutMs,
100
+ env: {
101
+ ...process.env,
102
+ CI: 'true',
103
+ DEBIAN_FRONTEND: 'noninteractive',
104
+ },
105
+ })
106
+
107
+ const timer = setTimeout(() => {
108
+ timedOut = true
109
+ try { proc.kill('SIGTERM') } catch { /* ignore */ }
110
+ }, timeoutMs)
111
+
112
+ proc.stdout?.on('data', (chunk) => {
113
+ if (stdout.length < MAX_OUTPUT) {
114
+ stdout += chunk.toString('utf8')
115
+ }
116
+ })
117
+
118
+ proc.stderr?.on('data', (chunk) => {
119
+ if (stderr.length < MAX_OUTPUT) {
120
+ stderr += chunk.toString('utf8')
121
+ }
122
+ })
123
+
124
+ proc.on('error', (err) => {
125
+ clearTimeout(timer)
126
+ const durationMs = Date.now() - start
127
+ stderr += '\n' + (err.message || String(err))
128
+ resolve({
129
+ passed: false,
130
+ command,
131
+ exitCode: 1,
132
+ stdout,
133
+ stderr,
134
+ logSnippet: (stdout + '\n' + stderr).trim().slice(-4000),
135
+ durationMs,
136
+ })
137
+ })
138
+
139
+ proc.on('close', (code, signal) => {
140
+ clearTimeout(timer)
141
+ const durationMs = Date.now() - start
142
+ if (timedOut || signal === 'SIGTERM') {
143
+ stderr += `\n[Verification Gate] Execution timed out after ${timeoutMs}ms.`
144
+ }
145
+ const exitCode = typeof code === 'number' ? code : (timedOut ? 124 : 1)
146
+ const passed = exitCode === 0
147
+ const logSnippet = (stdout + '\n' + stderr).trim().slice(-4000)
148
+ resolve({
149
+ passed,
150
+ command,
151
+ exitCode,
152
+ stdout,
153
+ stderr,
154
+ logSnippet,
155
+ durationMs,
156
+ })
157
+ })
158
+ })
159
+ }
160
+
161
+ /**
162
+ * Send failure prompt to agent session if active.
163
+ */
164
+ export function sendVerificationFailurePrompt({ agents, task, createMessage, command, exitCode, logSnippet }) {
165
+ const sessionId = typeof task?.sessionId === 'string' ? task.sessionId : ''
166
+ if (!sessionId || !agents) return false
167
+ let agent
168
+ try { agent = agents.get(sessionId) } catch { agent = undefined }
169
+ if (!agent || typeof agent.followup !== 'function') return false
170
+
171
+ const text = "Automated verification before Review failed (" + command + ", exit code " + exitCode + ").\nFix the test failures:\n\n```\n" + logSnippet + "\n```"
172
+ try {
173
+ const msg = typeof createMessage === 'function'
174
+ ? createMessage({
175
+ content: [{ type: 'text', text }],
176
+ source: { kind: 'dsh-kanban', plugin: 'dsh-kanban', form: 'verification-failure' },
177
+ })
178
+ : { content: [{ type: 'text', text }] }
179
+ agent.followup(msg)
180
+ return true
181
+ } catch {
182
+ return false
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Check and run verification gate before move to review, recording outcome.
188
+ *
189
+ * @returns {Promise<{ allowed: boolean, verification: object }>}
190
+ */
191
+ export async function verifyAndRecordGate({
192
+ task,
193
+ config,
194
+ store,
195
+ agents,
196
+ createMessage,
197
+ spawnRunner,
198
+ }) {
199
+ const vRes = await runVerificationGate({ task, config, spawnRunner })
200
+ if (vRes.skipped) {
201
+ return { allowed: true, verification: vRes }
202
+ }
203
+
204
+ const currentReport = typeof task.report === 'object' && task.report ? { ...task.report } : {}
205
+ currentReport.verification = {
206
+ status: vRes.passed ? 'passed' : 'failed',
207
+ command: vRes.command,
208
+ exitCode: vRes.exitCode,
209
+ durationMs: vRes.durationMs,
210
+ verifiedAt: Date.now(),
211
+ log: vRes.logSnippet,
212
+ }
213
+
214
+ if (store) {
215
+ store.setReport(task.id, currentReport)
216
+ store.updateTask(task.id, {
217
+ verificationStatus: vRes.passed ? 'passed' : 'failed',
218
+ })
219
+
220
+ if (!vRes.passed) {
221
+ store.addComment(task.id, {
222
+ author: 'system',
223
+ role: 'system',
224
+ text: `[Verification Gate Failed]: \`${vRes.command}\` exited with code ${vRes.exitCode}.\n\`\`\`\n${vRes.logSnippet.slice(-2000)}\n\`\`\``,
225
+ })
226
+ }
227
+ }
228
+
229
+ if (!vRes.passed) {
230
+ sendVerificationFailurePrompt({
231
+ agents,
232
+ task,
233
+ createMessage,
234
+ command: vRes.command,
235
+ exitCode: vRes.exitCode,
236
+ logSnippet: vRes.logSnippet,
237
+ })
238
+ return { allowed: false, verification: vRes }
239
+ }
240
+
241
+ return { allowed: true, verification: vRes }
242
+ }
package/lib/worktree.js CHANGED
@@ -13,7 +13,7 @@
13
13
 
14
14
  import { execFile } from 'node:child_process'
15
15
  import { promisify } from 'node:util'
16
- import { existsSync, readFileSync, readdirSync } from 'node:fs'
16
+ import { existsSync, readFileSync, readdirSync, rmSync } from 'node:fs'
17
17
  import { basename, relative } from 'node:path'
18
18
  import { isAbsolute, join, resolve, sep } from 'node:path'
19
19
 
@@ -752,3 +752,164 @@ export async function prepareMultiRepoMirror({ task, workspacePath, gitRunner =
752
752
 
753
753
  return { mirrors, errors: errors.length > 0 ? errors : undefined }
754
754
  }
755
+
756
+
757
+ /**
758
+ * Diff inspector returning structured files list with numstat counts (#371).
759
+ *
760
+ * @param {{worktreePath: string, baseRef?: string, gitRunner?: Function}} options
761
+ * @returns {Promise<{
762
+ * worktree: boolean,
763
+ * baseRef: string,
764
+ * files: Array<{path: string, status: string, insertions: number, deletions: number}>,
765
+ * diff: string,
766
+ * truncated: boolean,
767
+ * filesChanged: number
768
+ * }>}
769
+ */
770
+ export async function getTaskDiffInspector({ worktreePath, baseRef, gitRunner = defaultGitRunner }) {
771
+ if (!worktreePath) return { worktree: false, files: [], diff: '', filesChanged: 0, truncated: false }
772
+ const isRepo = await isGitRepo(worktreePath, gitRunner)
773
+ if (!isRepo) return { worktree: false, files: [], diff: '', filesChanged: 0, truncated: false }
774
+
775
+ let base = baseRef
776
+ if (!base) {
777
+ base = await determineBaseRef(worktreePath, gitRunner)
778
+ }
779
+
780
+ // 1. Map files from status --porcelain=v1 -u
781
+ const statusRes = await gitRunner(['status', '--porcelain=v1', '-u'], worktreePath)
782
+ const statusLines = (statusRes?.stdout || '').trim().split('\n').filter(Boolean)
783
+ const filesMap = new Map()
784
+
785
+ for (const line of statusLines) {
786
+ const code = line.slice(0, 2)
787
+ let filePath = line.slice(3).trim()
788
+ if (filePath.includes(' -> ')) {
789
+ filePath = filePath.split(' -> ')[1].trim()
790
+ }
791
+ if (filePath.startsWith('"') && filePath.endsWith('"')) {
792
+ filePath = filePath.slice(1, -1)
793
+ }
794
+
795
+ let status = 'modified'
796
+ if (code === '??') status = 'untracked'
797
+ else if (code.includes('A')) status = 'added'
798
+ else if (code.includes('D')) status = 'deleted'
799
+ else if (code.includes('R')) status = 'renamed'
800
+ else if (code.includes('M')) status = 'modified'
801
+
802
+ filesMap.set(filePath, { path: filePath, status, insertions: 0, deletions: 0 })
803
+ }
804
+
805
+ // 2. Line counts from git diff --numstat
806
+ if (base) {
807
+ const numstatBase = await gitRunner(['diff', '--numstat', `${base}...HEAD`], worktreePath)
808
+ if ((numstatBase?.exitCode ?? numstatBase?.code ?? 0) === 0) {
809
+ for (const line of (numstatBase.stdout || '').trim().split('\n').filter(Boolean)) {
810
+ const parts = line.split('\t')
811
+ if (parts.length >= 3) {
812
+ const ins = parseInt(parts[0], 10) || 0
813
+ const del = parseInt(parts[1], 10) || 0
814
+ let p = parts[2].trim()
815
+ if (p.includes(' => ')) {
816
+ p = p.replace(/.*=>\s*/, '').replace(/[{}]/g, '').trim()
817
+ }
818
+ if (p.startsWith('"') && p.endsWith('"')) p = p.slice(1, -1)
819
+ const existing = filesMap.get(p)
820
+ if (existing) {
821
+ existing.insertions += ins
822
+ existing.deletions += del
823
+ } else {
824
+ filesMap.set(p, { path: p, status: 'modified', insertions: ins, deletions: del })
825
+ }
826
+ }
827
+ }
828
+ }
829
+ }
830
+
831
+ const numstatHead = await gitRunner(['diff', '--numstat', 'HEAD'], worktreePath)
832
+ if ((numstatHead?.exitCode ?? numstatHead?.code ?? 0) === 0) {
833
+ for (const line of (numstatHead.stdout || '').trim().split('\n').filter(Boolean)) {
834
+ const parts = line.split('\t')
835
+ if (parts.length >= 3) {
836
+ const ins = parseInt(parts[0], 10) || 0
837
+ const del = parseInt(parts[1], 10) || 0
838
+ let p = parts[2].trim()
839
+ if (p.includes(' => ')) {
840
+ p = p.replace(/.*=>\s*/, '').replace(/[{}]/g, '').trim()
841
+ }
842
+ if (p.startsWith('"') && p.endsWith('"')) p = p.slice(1, -1)
843
+ const existing = filesMap.get(p)
844
+ if (existing) {
845
+ existing.insertions += ins
846
+ existing.deletions += del
847
+ } else {
848
+ filesMap.set(p, { path: p, status: 'modified', insertions: ins, deletions: del })
849
+ }
850
+ }
851
+ }
852
+ }
853
+
854
+ // 3. Diff content (with 500 KB ceiling per #371)
855
+ const diffRes = await getTaskDiff({ worktreePath, baseRef: base, gitRunner })
856
+ const files = Array.from(filesMap.values())
857
+
858
+ return {
859
+ worktree: true,
860
+ baseRef: base || '',
861
+ files,
862
+ diff: diffRes.diff,
863
+ truncated: diffRes.truncated,
864
+ filesChanged: files.length > 0 ? files.length : diffRes.filesChanged,
865
+ }
866
+ }
867
+
868
+ /**
869
+ * Discard changes to a specific file in task worktree safely (#371).
870
+ *
871
+ * @param {{worktreePath: string, file: string, gitRunner?: Function}} options
872
+ * @returns {Promise<{success: boolean, file?: string, tracked?: boolean, error?: string}>}
873
+ */
874
+ export async function discardTaskFile({ worktreePath, file, gitRunner = defaultGitRunner }) {
875
+ if (!worktreePath || !file || typeof file !== 'string') {
876
+ return { success: false, error: 'invalid-parameters' }
877
+ }
878
+
879
+ // Path fencing check: prevent directory traversal or outside deletion
880
+ if (isAbsolute(file) || file.includes('..')) {
881
+ return { success: false, error: 'path-traversal-detected' }
882
+ }
883
+
884
+ const fullPath = resolve(worktreePath, file)
885
+ const rootResolved = resolve(worktreePath)
886
+ if (!fullPath.startsWith(rootResolved + sep) && fullPath !== rootResolved) {
887
+ return { success: false, error: 'path-outside-worktree' }
888
+ }
889
+
890
+ // Check if file is tracked by git
891
+ let isTracked = false
892
+ try {
893
+ const lsRes = await gitRunner(['ls-files', '--error-unmatch', file], worktreePath)
894
+ isTracked = (lsRes?.exitCode ?? lsRes?.code ?? 0) === 0
895
+ } catch {
896
+ isTracked = false
897
+ }
898
+
899
+ if (isTracked) {
900
+ // Restore staged and unstaged
901
+ await gitRunner(['restore', '--staged', '--worktree', '--', file], worktreePath)
902
+ await gitRunner(['checkout', 'HEAD', '--', file], worktreePath)
903
+ return { success: true, file, tracked: true }
904
+ } else {
905
+ // Untracked file: remove safely
906
+ try {
907
+ if (existsSync(fullPath)) {
908
+ rmSync(fullPath, { recursive: true, force: true })
909
+ }
910
+ return { success: true, file, tracked: false }
911
+ } catch (e) {
912
+ return { success: false, error: e?.message || 'failed-to-delete-untracked-file' }
913
+ }
914
+ }
915
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-kanban",
3
- "version": "0.2.31",
3
+ "version": "0.2.32",
4
4
  "description": "Visual Kanban board for DeepSeek Harness: Gitea and GitHub-backed tasks, workflow columns, and a dedicated agent session per task.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",