@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.
@@ -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.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",