@goodandready/dsh-moa 0.2.60 → 0.2.62

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,51 @@
1
+ import { logger } from '../logger.js'
2
+ import { bestEffort } from '../best-effort.js'
3
+ import { isLoopbackUrl } from './security.js'
4
+
5
+ export async function createPrePromotionCheckpoint(baseDir, candidateTarget, options = {}) {
6
+ if (!baseDir || options.checkpoint === false) return null
7
+ if (typeof options.checkpointFn === 'function') {
8
+ return (await bestEffort('custom checkpointFn', () => options.checkpointFn(baseDir, candidateTarget))) || null
9
+ }
10
+
11
+ const label = options.checkpointLabel || `moa-pre-promotion: candidate-${candidateTarget}`
12
+ if (options.timeMachineEngine && typeof options.timeMachineEngine.createSnapshot === 'function') {
13
+ const snap = await bestEffort('dsh-time-machine direct service createSnapshot', async () => {
14
+ return await options.timeMachineEngine.createSnapshot(label, { cwd: baseDir })
15
+ })
16
+ if (snap) return snap
17
+ }
18
+
19
+ const port = options.port || options.webServerPort || process.env.DSH_PORT || process.env.PORT
20
+ if (!port && !options.timeMachineUrl) {
21
+ logger.warn('[dsh-moa] Cannot create pre-promotion checkpoint: webServerPort is unknown and no timeMachineUrl provided')
22
+ return null
23
+ }
24
+
25
+ if (options.timeMachineUrl && !isLoopbackUrl(options.timeMachineUrl)) {
26
+ logger.warn(`[dsh-moa] Blocked non-loopback timeMachineUrl: ${options.timeMachineUrl}`)
27
+ return null
28
+ }
29
+
30
+ const endpoint = options.timeMachineUrl || `http://127.0.0.1:${port}/dsh-time-machine/create`
31
+ const snap = await bestEffort('dsh-time-machine pre-promotion checkpoint', async () => {
32
+ const res = await fetch(endpoint, {
33
+ method: 'POST',
34
+ headers: { 'Content-Type': 'application/json' },
35
+ body: JSON.stringify({ label, cwd: baseDir }),
36
+ signal: AbortSignal.timeout(options.timeoutMs ?? 1500),
37
+ redirect: 'error',
38
+ })
39
+ if (res.ok) {
40
+ const data = await res.json()
41
+ return data?.snapshot || data || null
42
+ }
43
+ return null
44
+ })
45
+ return snap || null
46
+ }
47
+
48
+ /**
49
+ * Promotes winning candidate workspace files into the base workspace.
50
+ * Serialized per baseDir and verifies symlink boundaries on promotion.
51
+ */
@@ -0,0 +1,94 @@
1
+ import path from 'node:path'
2
+ import vm from 'node:vm'
3
+
4
+ export function verifyFileSyntax(files = []) {
5
+ if (!Array.isArray(files)) return []
6
+ const warnings = []
7
+
8
+ for (const file of files) {
9
+ if (!file || typeof file.content !== 'string' || !file.relativePath) continue
10
+ const ext = path.extname(file.relativePath).toLowerCase()
11
+
12
+ if (ext === '.js' || ext === '.mjs' || ext === '.cjs') {
13
+ try {
14
+ const transformed = file.content
15
+ .replace(/^([ \t]*)export\s+default\s+/gm, '$1')
16
+ .replace(/^([ \t]*)export\s*\{[^}]*\}\s*;?/gm, '$1')
17
+ .replace(/^([ \t]*)export\s+(const|let|var|function|class|async\s+function)\s+/gm, '$1$2 ')
18
+ .replace(/^([ \t]*)import\s+[^;\n]+;?/gm, '$1')
19
+ .replace(/^([ \t]*)export\s+\*\s+from\s+[^;\n]+;?/gm, '$1')
20
+ new vm.Script(transformed)
21
+ } catch (err) {
22
+ warnings.push({ file: file.relativePath, error: err?.message || String(err), line: err?.lineNumber || 1 })
23
+ }
24
+ } else if (ext === '.json') {
25
+ try {
26
+ JSON.parse(file.content)
27
+ } catch (err) {
28
+ warnings.push({
29
+ file: file.relativePath,
30
+ error: err?.message || String(err),
31
+ line: 1,
32
+ })
33
+ }
34
+ }
35
+ }
36
+ return warnings
37
+ }
38
+
39
+ /**
40
+ * Reads all files from a candidate workspace (.moa/candidate-N, .moa/curator-synthesis, or scoped under runId).
41
+ */
42
+
43
+ export function computeLineDiff(oldText = '', newText = '') {
44
+ const oldLines = typeof oldText === 'string' ? oldText.split(/\r?\n/) : []
45
+ const newLines = typeof newText === 'string' ? newText.split(/\r?\n/) : []
46
+ const n = oldLines.length, m = newLines.length
47
+ if (n === 0 && m === 0) return []
48
+ if (oldText === newText) {
49
+ return oldLines.map((line, idx) => ({ type: 'same', line, oldNum: idx + 1, newNum: idx + 1 }))
50
+ }
51
+
52
+ let start = 0
53
+ while (start < n && start < m && oldLines[start] === newLines[start]) start++
54
+ let oldEnd = n - 1, newEnd = m - 1
55
+ while (oldEnd >= start && newEnd >= start && oldLines[oldEnd] === newLines[newEnd]) {
56
+ oldEnd--
57
+ newEnd--
58
+ }
59
+
60
+ const prefixItems = oldLines.slice(0, start).map((line, k) => ({ type: 'same', line, oldNum: k + 1, newNum: k + 1 }))
61
+ const suffixLen = (n - 1) - oldEnd
62
+ const suffixItems = oldLines.slice(oldEnd + 1, oldEnd + 1 + suffixLen).map((line, k) => ({ type: 'same', line, oldNum: oldEnd + 2 + k, newNum: newEnd + 2 + k }))
63
+
64
+ const subOld = oldLines.slice(start, oldEnd + 1)
65
+ const subNew = newLines.slice(start, newEnd + 1)
66
+ const subN = subOld.length, subM = subNew.length
67
+ let middleItems
68
+ if (subN === 0) {
69
+ middleItems = subNew.map((line, j) => ({ type: 'added', line, newNum: start + j + 1 }))
70
+ } else if (subM === 0) {
71
+ middleItems = subOld.map((line, i) => ({ type: 'removed', line, oldNum: start + i + 1 }))
72
+ } else if (subN > 2000 || subM > 2000) {
73
+ middleItems = new Array(subN + subM)
74
+ for (let i = 0; i < subN; i++) middleItems[i] = { type: 'removed', line: subOld[i], oldNum: start + i + 1 }
75
+ for (let j = 0; j < subM; j++) middleItems[subN + j] = { type: 'added', line: subNew[j], newNum: start + j + 1 }
76
+ } else {
77
+ const dp = Array.from({ length: subN + 1 }, () => new Uint16Array(subM + 1))
78
+ for (let i = 0; i < subN; i++) {
79
+ for (let j = 0; j < subM; j++) dp[i + 1][j + 1] = subOld[i] === subNew[j] ? dp[i][j] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1])
80
+ }
81
+ let i = subN, j = subM; const stack = []
82
+ while (i > 0 || j > 0) {
83
+ if (i > 0 && j > 0 && subOld[i - 1] === subNew[j - 1]) {
84
+ stack.push({ type: 'same', line: subOld[--i], oldNum: start + i + 1, newNum: start + j }); j--
85
+ } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
86
+ stack.push({ type: 'added', line: subNew[--j], newNum: start + j + 1 })
87
+ } else if (i > 0) {
88
+ stack.push({ type: 'removed', line: subOld[--i], oldNum: start + i + 1 })
89
+ }
90
+ }
91
+ middleItems = stack.reverse()
92
+ }
93
+ return prefixItems.concat(middleItems, suffixItems)
94
+ }
@@ -0,0 +1,284 @@
1
+ import { bestEffort } from '../best-effort.js'
2
+ import { logger } from '../logger.js'
3
+ import fs from 'node:fs/promises'
4
+ import fsSync from 'node:fs'
5
+ import path from 'node:path'
6
+ import { isSafeRelativePath, assertPathContained } from './security.js'
7
+ import { createPrePromotionCheckpoint } from './checkpoint.js'
8
+
9
+ const promotionLocks = new Map()
10
+
11
+ export function resolveCandidateFolder(baseDir, candidateTarget, options = {}) {
12
+ const targetStr = String(candidateTarget ?? 1).trim()
13
+ const sanitizedTarget = targetStr.replace(/[^a-zA-Z0-9_-]/g, '')
14
+ const sub = /^\d+$/.test(sanitizedTarget) ? `candidate-${sanitizedTarget}` : (sanitizedTarget || 'candidate-1')
15
+ const rawRunId = typeof options === 'string' ? options : options?.runId
16
+ const safeRunId = rawRunId ? String(rawRunId).trim().replace(/[^a-zA-Z0-9_-]/g, '') : null
17
+ const moaRoot = assertPathContained(baseDir, '.moa')
18
+ const runRoot = safeRunId ? assertPathContained(moaRoot, safeRunId) : moaRoot
19
+ return { moaRoot, runRoot, safeRunId, sub, candidateDir: assertPathContained(runRoot, sub) }
20
+ }
21
+
22
+ /**
23
+ * Parses markdown code blocks that specify file paths.
24
+ */
25
+ export function extractFileBlocks(text) {
26
+ if (!text || typeof text !== 'string') return []
27
+ const files = []
28
+ const seenPaths = new Set()
29
+ const addFile = (rawPath, fileContent) => {
30
+ if (rawPath && isSafeRelativePath(rawPath) && !seenPaths.has(rawPath)) {
31
+ seenPaths.add(rawPath)
32
+ files.push({ relativePath: rawPath, content: fileContent.trimEnd() + '\n' })
33
+ }
34
+ }
35
+
36
+ const patterns = [
37
+ /```[\w-]*\s+(?:file|path|filepath)=["']?([^\s"'\n\r]+)["']?[\r\n]+([\s\S]*?)```/gi,
38
+ /(?:\/\/|#|<!--)\s*(?:File|Path):\s*([^\s\n\r>]+)(?:\s*-->)?[\r\n]+```[\w-]*[\r\n]+([\s\S]*?)```/gi,
39
+ /#{1,4}\s+(?:File|Path):\s*([^\s\n\r]+)[\r\n]+```[\w-]*[\r\n]+([\s\S]*?)```/gi,
40
+ /```[\w-]*[\r\n]+(?:\/\/|#|<!--)\s*(?:File|Path):\s*([^\s\n\r>]+)(?:\s*-->)?[\r\n]+([\s\S]*?)```/gi,
41
+ ]
42
+ for (const regex of patterns) {
43
+ let match
44
+ while ((match = regex.exec(text)) !== null) addFile(match[1].trim(), match[2])
45
+ }
46
+
47
+ if (files.length === 0) {
48
+ const htmlBlockRegex = /```(?:html)?[\r\n]+([\s\S]*?)```/gi
49
+ let htmlMatch
50
+ while ((htmlMatch = htmlBlockRegex.exec(text)) !== null) {
51
+ const candidateHtml = htmlMatch[1]
52
+ if (/<(!DOCTYPE\s+)?html/i.test(candidateHtml) || /<body/i.test(candidateHtml)) {
53
+ files.push({ relativePath: 'index.html', content: candidateHtml.trimEnd() + '\n' })
54
+ break
55
+ }
56
+ }
57
+ }
58
+ return files
59
+ }
60
+
61
+ /**
62
+ * Formats collected project files into a structured markdown context string.
63
+ */
64
+
65
+ export async function writeCandidateWorkspace(baseDir, candidateIndex, files = [], options = {}) {
66
+ if (!baseDir || !Array.isArray(files) || files.length === 0) return []
67
+ const { runRoot, sub, candidateDir } = resolveCandidateFolder(baseDir, candidateIndex, options)
68
+ await fs.mkdir(runRoot, { recursive: true })
69
+
70
+ const tempSub = `.tmp-${sub}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
71
+ const tempCandidateDir = assertPathContained(runRoot, tempSub)
72
+ await fs.mkdir(tempCandidateDir, { recursive: true })
73
+
74
+ try {
75
+ const written = []
76
+ for (const file of files) {
77
+ if (!isSafeRelativePath(file.relativePath)) continue
78
+ const fullPath = assertPathContained(tempCandidateDir, file.relativePath)
79
+ await fs.mkdir(path.dirname(fullPath), { recursive: true })
80
+ await fs.writeFile(fullPath, file.content, 'utf8')
81
+ written.push({
82
+ relativePath: file.relativePath,
83
+ fullPath: path.join(candidateDir, file.relativePath),
84
+ size: Buffer.byteLength(file.content, 'utf8'),
85
+ })
86
+ }
87
+
88
+ if (written.length === 0 && files.length > 0) throw new Error('No valid files could be written to candidate workspace')
89
+ await fs.rm(candidateDir, { recursive: true, force: true })
90
+ await fs.rename(tempCandidateDir, candidateDir)
91
+ return written
92
+ } catch (err) {
93
+ await bestEffort('clean tempCandidateDir on error', () => fs.rm(tempCandidateDir, { recursive: true, force: true }))
94
+ throw err
95
+ }
96
+ }
97
+
98
+
99
+ export async function promoteCandidateWorkspace(baseDir, winningIndex, options = { keepMoa: false }) {
100
+ if (!baseDir || winningIndex === null || winningIndex === undefined) return []
101
+
102
+ const key = path.resolve(baseDir)
103
+ const prevLock = promotionLocks.get(key) || Promise.resolve()
104
+ let releaseLock
105
+ const currentLock = new Promise((resolve) => { releaseLock = resolve })
106
+ promotionLocks.set(key, currentLock)
107
+
108
+ try {
109
+ await prevLock
110
+ return await doPromoteCandidateWorkspace(baseDir, winningIndex, options)
111
+ } finally {
112
+ releaseLock()
113
+ if (promotionLocks.get(key) === currentLock) {
114
+ promotionLocks.delete(key)
115
+ }
116
+ }
117
+ }
118
+
119
+ async function doPromoteCandidateWorkspace(baseDir, winningIndex, options = { keepMoa: false }) {
120
+ const { runRoot, moaRoot, safeRunId, candidateDir: winningDir } = resolveCandidateFolder(baseDir, winningIndex, options)
121
+ const cleanupTarget = safeRunId ? runRoot : moaRoot
122
+
123
+ try {
124
+ const entries = await getFilesRecursively(winningDir)
125
+ if (entries.length === 0) {
126
+ if (!options.keepMoa) await bestEffort('clean empty candidate folder', () => fs.rm(cleanupTarget, { recursive: true, force: true }))
127
+ return []
128
+ }
129
+
130
+ const checkpoint = await createPrePromotionCheckpoint(baseDir, winningIndex, options)
131
+ if (options.checkpoint !== false && !checkpoint && !options.force) {
132
+ const errMsg = `[dsh-moa] Pre-promotion checkpoint failed for candidate-${winningIndex} in ${baseDir}. File overwrite blocked to prevent uncheckpointed changes (pass force: true to override).`
133
+ logger.warn(errMsg)
134
+ throw new Error(errMsg)
135
+ }
136
+ if (options.signal?.aborted) {
137
+ throw (options.signal.reason instanceof Error ? options.signal.reason : new Error('Turn aborted'))
138
+ }
139
+
140
+ const promoted = []
141
+ const baseReal = await fs.realpath(baseDir).catch(() => path.resolve(baseDir))
142
+
143
+ for (const rel of entries) {
144
+ if (!isSafeRelativePath(rel)) continue
145
+ const srcPath = assertPathContained(winningDir, rel)
146
+ const destPath = assertPathContained(baseDir, rel)
147
+
148
+ try {
149
+ const destLstat = await fs.lstat(destPath)
150
+ if (destLstat.isSymbolicLink()) {
151
+ const destReal = await fs.realpath(destPath)
152
+ if (destReal !== baseReal && !destReal.startsWith(baseReal + path.sep)) {
153
+ throw new Error(`Path traversal violation: promotion destination symlink "${rel}" resolves outside "${baseDir}"`)
154
+ }
155
+ await fs.unlink(destPath)
156
+ }
157
+ } catch (lstatErr) {
158
+ if (lstatErr?.code !== 'ENOENT') throw lstatErr
159
+ }
160
+
161
+ await fs.mkdir(path.dirname(destPath), { recursive: true })
162
+ await fs.copyFile(srcPath, destPath)
163
+ promoted.push(rel)
164
+ }
165
+
166
+ if (checkpoint) promoted.checkpoint = checkpoint
167
+ if (!options.keepMoa) await fs.rm(cleanupTarget, { recursive: true, force: true })
168
+
169
+ return promoted
170
+ } catch (err) {
171
+ if (!options.keepMoa) {
172
+ await bestEffort('clean candidate on promote error', () => fs.rm(cleanupTarget, { recursive: true, force: true }))
173
+ }
174
+ throw err
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Cleans up candidate workspaces, optionally scoped to a single runId.
180
+ */
181
+ export async function cleanMoaWorkspaces(baseDir, options = {}) {
182
+ if (!baseDir) return
183
+ const rawRunId = typeof options === 'string' ? options : options?.runId
184
+ const safeRunId = rawRunId ? String(rawRunId).trim().replace(/[^a-zA-Z0-9_-]/g, '') : null
185
+ const moaRoot = assertPathContained(baseDir, '.moa')
186
+ if (safeRunId) {
187
+ await bestEffort('cleanMoaWorkspaces runId', () => fs.rm(assertPathContained(moaRoot, safeRunId), { recursive: true, force: true }))
188
+ await bestEffort('clean empty moaRoot', async () => {
189
+ if ((await fs.readdir(moaRoot)).length === 0) await fs.rmdir(moaRoot)
190
+ })
191
+ } else {
192
+ await bestEffort('cleanMoaWorkspaces', () => fs.rm(moaRoot, { recursive: true, force: true }))
193
+ }
194
+ }
195
+
196
+ async function getFilesRecursively(dir) {
197
+ const results = []
198
+ try {
199
+ const stat = await fs.stat(dir)
200
+ if (!stat.isDirectory()) return []
201
+ } catch {
202
+ return []
203
+ }
204
+
205
+ let realDir
206
+ try {
207
+ realDir = await fs.realpath(dir)
208
+ } catch {
209
+ realDir = path.resolve(dir)
210
+ }
211
+
212
+ async function scan(current, relPrefix = '') {
213
+ let list
214
+ try {
215
+ list = await fs.readdir(current, { withFileTypes: true })
216
+ for (const item of list) {
217
+ const rel = relPrefix ? `${relPrefix}/${item.name}` : item.name
218
+ const full = path.join(current, item.name)
219
+
220
+ if (item.isSymbolicLink()) {
221
+ try {
222
+ const realTarget = await fs.realpath(full)
223
+ if (realTarget === realDir || realTarget.startsWith(realDir + path.sep)) {
224
+ const st = await fs.stat(full)
225
+ if (st.isFile()) results.push(rel)
226
+ }
227
+ } catch (err) { logger.debug?.('[dsh-moa] symlink inspect skipped', err) }
228
+ continue
229
+ }
230
+
231
+ if (item.isDirectory()) await scan(full, rel)
232
+ else if (item.isFile()) results.push(rel)
233
+ }
234
+ } catch {
235
+ return
236
+ }
237
+ }
238
+ await scan(dir)
239
+ return results
240
+ }
241
+
242
+ /**
243
+ * Verifies the syntactic validity of generated code files.
244
+ */
245
+
246
+ export async function readCandidateFiles(baseDir, candidateTarget, options = {}) {
247
+ if (!baseDir || candidateTarget === undefined || candidateTarget === null) return []
248
+ let targetDir
249
+ try {
250
+ targetDir = resolveCandidateFolder(baseDir, candidateTarget, options).candidateDir
251
+ } catch {
252
+ return []
253
+ }
254
+
255
+ try {
256
+ const relFiles = await getFilesRecursively(targetDir)
257
+ const result = []
258
+ const realTarget = await fs.realpath(targetDir).catch(() => targetDir)
259
+
260
+ for (const rel of relFiles) {
261
+ if (!isSafeRelativePath(rel)) continue
262
+ try {
263
+ const fullPath = assertPathContained(targetDir, rel)
264
+ const st = await fs.lstat(fullPath)
265
+ if (st.isSymbolicLink()) {
266
+ const realFile = await fs.realpath(fullPath)
267
+ if (realFile !== realTarget && !realFile.startsWith(realTarget + path.sep)) continue
268
+ }
269
+ const content = await fs.readFile(fullPath, 'utf8')
270
+ result.push({ relativePath: rel, content, size: Buffer.byteLength(content, 'utf8') })
271
+ } catch {
272
+ // file read error best-effort
273
+ }
274
+ }
275
+ return result
276
+ } catch {
277
+ return []
278
+ }
279
+ }
280
+
281
+ /**
282
+ * Computes a line-by-line diff between two text strings using LCS with
283
+ * common prefix and suffix trimming for optimal O(delta) performance.
284
+ */
@@ -0,0 +1,100 @@
1
+ import fs from 'node:fs/promises'
2
+ import fsSync from 'node:fs'
3
+ import path from 'node:path'
4
+
5
+ export function formatProjectContext(projectFiles = [], options = {}) {
6
+ if (!Array.isArray(projectFiles) || projectFiles.length === 0) return ''
7
+ const { skippedFiles = 0, skippedList = [] } = options
8
+ const fence = '```'
9
+ let formatted = projectFiles
10
+ .map((f) => `### File: ${f.relativePath}\n${fence}\n${f.content}\n${fence}`)
11
+ .join('\n\n')
12
+
13
+ if (skippedFiles > 0) {
14
+ const preview = skippedList.slice(0, 5).join(', ')
15
+ const more = skippedList.length > 5 ? ` and ${skippedList.length - 5} more` : ''
16
+ const warning = `> ⚠️ **Workspace Scan Notice:** ${skippedFiles} workspace file(s) were skipped or truncated due to size/budget limits or read errors (${preview}${more}). The workspace context below is partial.`
17
+ formatted = `${warning}\n\n${formatted}`
18
+ }
19
+ return formatted
20
+ }
21
+
22
+ export async function collectProjectContext(baseDir, maxCharBudget = 16000) {
23
+ if (!baseDir) return { files: [], totalChars: 0, skippedFiles: 0, skippedList: [] }
24
+ const ignoreDirs = new Set(['node_modules', '.git', '.moa', '.worktrees', 'dist', 'build', '.dsh', '.next', '.nuxt', '.cache', 'coverage', '.turbo', '.idea', '.vscode'])
25
+ const codeExts = new Set(['.html', '.htm', '.css', '.js', '.mjs', '.cjs', '.jsx', '.ts', '.tsx', '.vue', '.svelte', '.json', '.py', '.md', '.txt', '.sh', '.yaml', '.yml'])
26
+ const collected = []
27
+ let totalChars = 0
28
+ let skippedFiles = 0
29
+ const skippedList = []
30
+
31
+ async function scan(currentDir, relPrefix = '') {
32
+ if (totalChars >= maxCharBudget) return
33
+ let list
34
+ try {
35
+ list = await fs.readdir(currentDir, { withFileTypes: true })
36
+ } catch {
37
+ skippedFiles++
38
+ skippedList.push(relPrefix || '.')
39
+ return
40
+ }
41
+
42
+ for (const item of list) {
43
+ if (item.name.startsWith('.')) continue
44
+ const rel = relPrefix ? `${relPrefix}/${item.name}` : item.name
45
+ const full = path.join(currentDir, item.name)
46
+
47
+ if (totalChars >= maxCharBudget) {
48
+ if (item.isFile() && codeExts.has(path.extname(item.name).toLowerCase())) {
49
+ skippedFiles++
50
+ skippedList.push(`${rel} (budget limit)`)
51
+ }
52
+ continue
53
+ }
54
+
55
+ if (item.isDirectory()) {
56
+ if (!ignoreDirs.has(item.name)) await scan(full, rel)
57
+ } else if (item.isFile()) {
58
+ const ext = path.extname(item.name).toLowerCase()
59
+ if (codeExts.has(ext)) {
60
+ try {
61
+ const stat = await fs.stat(full)
62
+ if (stat.size > 100000) {
63
+ skippedFiles++
64
+ skippedList.push(`${rel} (>100KB)`)
65
+ continue
66
+ }
67
+ const content = await fs.readFile(full, 'utf8')
68
+ const budgetRemaining = maxCharBudget - totalChars
69
+ const truncated = content.length > budgetRemaining ? content.slice(0, budgetRemaining) + '\n... [truncated]' : content
70
+ totalChars += truncated.length
71
+ collected.push({ relativePath: rel, content: truncated, size: stat.size })
72
+ } catch (err) {
73
+ skippedFiles++
74
+ skippedList.push(`${rel} (read error: ${err?.message || String(err)})`)
75
+ }
76
+ }
77
+ }
78
+ }
79
+ }
80
+
81
+ await scan(baseDir)
82
+ return { files: collected, totalChars, skippedFiles, skippedList }
83
+ }
84
+
85
+ /**
86
+ * Checks if a task prompt is an iterative refinement / modification.
87
+ */
88
+ export function isRefinementTask(userPrompt = '', projectFiles = null) {
89
+ if ((Array.isArray(projectFiles) && projectFiles.length === 0) || !userPrompt?.trim()) return false
90
+ const p = userPrompt.toLowerCase().trim()
91
+ const fresh = ['from scratch', 'new project', 'new app', 'create a new', 'build a new', 'generate a new', '从头开始', '新建项目', '创建项目', '新应用', '新建应用', 'с нуля', 'новый проект', 'новое приложение', 'создай проект', 'создай приложение', 'создай игру', 'создай сервис', 'сделай проект', 'сделай приложение', 'сделай игру']
92
+ if (fresh.some((phrase) => p.includes(phrase))) return false
93
+ const mod = ['add', 'change', 'update', 'fix', 'modify', 'refactor', 'improve', 'enhance', 'adjust', 'style', 'css', 'color', 'theme', 'bug', '添加', '修改', '更新', '修复', '重构', '改进', '调整', '样式', '颜色', '主题', 'добавь', 'измени', 'поменяй', 'исправь', 'обнови', 'переделай', 'доработай', 'удали', 'темн', 'светл', 'цвет', 'кнопк', 'баг']
94
+ return mod.some((kw) => p.includes(kw))
95
+ }
96
+
97
+ /**
98
+ * Writes candidate files into an isolated candidate workspace.
99
+ * Uses atomic staging and swap to prevent stale file accumulation and dirty snapshots.
100
+ */
@@ -0,0 +1,80 @@
1
+ import fsSync from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ export function isSafeRelativePath(relPath) {
5
+ if (!relPath || typeof relPath !== 'string') return false
6
+ const trimmed = relPath.trim()
7
+ if (!trimmed || path.isAbsolute(trimmed) || trimmed.startsWith('/') || trimmed.startsWith('\\')) return false
8
+ const normalized = path.normalize(trimmed)
9
+ return !(!normalized || normalized === '.' || normalized.startsWith('..') || path.isAbsolute(normalized))
10
+ }
11
+
12
+ /**
13
+ * Asserts that a resolved path is strictly contained within baseDir, verifying
14
+ * both lexical boundaries and physical canonical symlink resolution.
15
+ */
16
+ export function assertPathContained(baseDir, subPath) {
17
+ const resolvedBase = path.resolve(baseDir)
18
+ const resolvedTarget = path.resolve(resolvedBase, subPath)
19
+ if (resolvedTarget !== resolvedBase && !resolvedTarget.startsWith(resolvedBase + path.sep)) {
20
+ throw new Error(`Path traversal violation: "${subPath}" escapes base directory "${baseDir}"`)
21
+ }
22
+
23
+ try {
24
+ const realBase = fsSync.realpathSync(resolvedBase)
25
+ try {
26
+ const realTarget = fsSync.realpathSync(resolvedTarget)
27
+ if (realTarget !== realBase && !realTarget.startsWith(realBase + path.sep)) {
28
+ throw new Error(`Path traversal violation: symlink "${subPath}" resolves outside "${baseDir}"`)
29
+ }
30
+ } catch (targetErr) {
31
+ if (targetErr?.code === 'ENOENT') {
32
+ let curr = path.dirname(resolvedTarget)
33
+ while (curr === resolvedBase || curr.startsWith(resolvedBase + path.sep)) {
34
+ try {
35
+ const realCurr = fsSync.realpathSync(curr)
36
+ if (realCurr !== realBase && !realCurr.startsWith(realBase + path.sep)) {
37
+ throw new Error(`Path traversal violation: symlink ancestor in "${subPath}" resolves outside "${baseDir}"`)
38
+ }
39
+ break
40
+ } catch (ancestorErr) {
41
+ if (ancestorErr?.code === 'ENOENT') {
42
+ const parent = path.dirname(curr)
43
+ if (parent === curr) break
44
+ curr = parent
45
+ continue
46
+ }
47
+ throw ancestorErr
48
+ }
49
+ }
50
+ } else {
51
+ throw targetErr
52
+ }
53
+ }
54
+ } catch (baseErr) {
55
+ if (baseErr?.message?.startsWith('Path traversal violation')) throw baseErr
56
+ }
57
+
58
+ return resolvedTarget
59
+ }
60
+
61
+ /**
62
+ * Resolves directory paths for candidate sandboxes with optional runId scoping.
63
+ */
64
+
65
+ export function isLoopbackUrl(urlStr) {
66
+ if (!urlStr || typeof urlStr !== 'string') return false
67
+ try {
68
+ const u = new URL(urlStr)
69
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return false
70
+ const h = u.hostname.replace(/^\[|\]$/g, '')
71
+ return h === 'localhost' || h === '127.0.0.1' || h === '::1' || /^127\.\d+\.\d+\.\d+$/.test(h)
72
+ } catch {
73
+ return false
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Best-effort integration with dsh-time-machine to create a shadow Git checkpoint
79
+ * before candidate promotion overwrites files in the project workspace.
80
+ */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-moa",
3
- "version": "0.2.60",
3
+ "version": "0.2.62",
4
4
  "description": "Mixture of Agents (MoA) plugin for DeepSeek Harness with /moa slash command",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",