@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 +15 -0
- package/lib/client.js +117 -11
- package/lib/config.js +6 -0
- package/lib/index.js +39 -2
- package/lib/routes.js +16 -1
- package/lib/store.js +3 -0
- package/lib/verification.js +242 -0
- package/lib/worktree.js +162 -1
- package/package.json +1 -1
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
|
-
:
|
|
3361
|
-
|
|
3362
|
-
style: {
|
|
3363
|
-
|
|
3364
|
-
|
|
3365
|
-
|
|
3366
|
-
|
|
3367
|
-
|
|
3368
|
-
|
|
3369
|
-
|
|
3370
|
-
|
|
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.
|
|
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",
|