@goodandready/dsh-kanban 0.2.31 → 0.2.33
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 +378 -12
- package/lib/config.js +10 -0
- package/lib/cost-telemetry.js +146 -0
- package/lib/index.js +68 -3
- package/lib/routes.js +104 -4
- package/lib/store.js +25 -0
- package/lib/verification.js +242 -0
- package/lib/worktree.js +162 -1
- package/package.json +1 -1
|
@@ -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.33",
|
|
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",
|