sillyspec 3.25.0 → 3.25.2
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/.claude/skills/sillyspec-doctor/SKILL.md +9 -0
- package/.playwright-mcp/console-2026-07-27T15-45-50-621Z.log +1 -0
- package/.playwright-mcp/page-2026-07-27T15-45-51-459Z.yml +144 -0
- package/.playwright-mcp/prompt-final.png +0 -0
- package/.playwright-mcp/prompt-view.png +0 -0
- package/CLAUDE.md +18 -0
- package/package.json +1 -1
- package/src/change-risk-profile.js +60 -12
- package/src/check-primitives.js +65 -0
- package/src/db.js +28 -8
- package/src/hooks/worktree-guard.js +9 -2
- package/src/index.js +140 -11
- package/src/machine-interface.js +13 -7
- package/src/progress/change-registry.js +217 -0
- package/src/progress/consistency-doctor.js +259 -0
- package/src/progress/shared.js +33 -0
- package/src/progress/stage-machine.js +535 -0
- package/src/progress/step-store.js +256 -0
- package/src/progress.js +75 -1191
- package/src/quicklog.js +3 -1
- package/src/run/command.js +1088 -0
- package/src/run/complete-handlers.js +874 -0
- package/src/run/complete.js +725 -0
- package/src/run/gates.js +326 -0
- package/src/run/prompt.js +619 -0
- package/src/run/quick-audit.js +107 -0
- package/src/run/scan-profile.js +238 -0
- package/src/run/shared.js +418 -0
- package/src/run/stage.js +414 -0
- package/src/run.js +23 -4379
- package/src/stage-contract-engine.js +150 -0
- package/src/stage-contract-spec.js +517 -0
- package/src/stage-contract.js +87 -150
- package/src/stage-review.js +63 -0
- package/src/stages/brainstorm.js +3 -1
- package/src/stages/execute.js +5 -2
- package/src/stages/plan-postcheck.js +38 -27
- package/src/stages/plan.js +12 -49
- package/src/stages/propose.js +2 -1
- package/src/stages/scan.js +3 -1
- package/src/workflow.js +9 -17
- package/src/worktree-apply.js +5 -2
- package/src/worktree-deps.js +114 -32
- package/src/worktree.js +23 -11
- package/src/brainstorm-postcheck.js +0 -158
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* run.js 共享纯工具(W6 Step1 从 run.js 抽出)。
|
|
3
|
+
*
|
|
4
|
+
* 安全锚:run.js 始终是 barrel。这些函数搬至此模块后,run.js import 回来;
|
|
5
|
+
* parsePorcelainPath + auditQuickCompletion 被 test 直接 import,run.js 必须 re-export。
|
|
6
|
+
*
|
|
7
|
+
* 路径修正(相对 src/run/):
|
|
8
|
+
* - resolvePromptIncludes 的 templates/prompts 在仓库根 → __dirname 上两层
|
|
9
|
+
* - triggerSync 的动态 import './sync.js' → '../sync.js'(src/sync.js)
|
|
10
|
+
* - safeGit 原用顶层 require('child_process'),改静态 import execSync(更 ESM-native)
|
|
11
|
+
*/
|
|
12
|
+
import { basename, join, resolve, dirname } from 'node:path'
|
|
13
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
14
|
+
import { execSync } from 'node:child_process'
|
|
15
|
+
import { fileURLToPath } from 'node:url'
|
|
16
|
+
|
|
17
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
18
|
+
|
|
19
|
+
// ── Wait State Constants ──(W6 Step3 从 run.js 搬入:prompt.js outputStep 与 run.js wait-detection 共用)
|
|
20
|
+
// 正则匹配:只识别独立一行的标记,避免误伤文档正文引用
|
|
21
|
+
export const WAIT_MARKER_RE = /^\s*\[(WAIT_FOR_USER|NEEDS_CONFIRM|NEEDS_DECISION)\]\s*$/m
|
|
22
|
+
|
|
23
|
+
// ── did-you-mean(命令级 + flag 级 typo 建议,零依赖)──
|
|
24
|
+
// 历史:agent 打错命令名/flag 名只报「未知」+整屏列表,要自己捞正确拼写。
|
|
25
|
+
export function levenshtein(a, b) {
|
|
26
|
+
const m = String(a).length, n = String(b).length
|
|
27
|
+
if (!m) return n; if (!n) return m
|
|
28
|
+
let prev = Array.from({ length: n + 1 }, (_, i) => i)
|
|
29
|
+
for (let i = 1; i <= m; i++) {
|
|
30
|
+
const cur = [i]
|
|
31
|
+
for (let j = 1; j <= n; j++) {
|
|
32
|
+
cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1))
|
|
33
|
+
}
|
|
34
|
+
prev = cur
|
|
35
|
+
}
|
|
36
|
+
return prev[n]
|
|
37
|
+
}
|
|
38
|
+
export function didYouMean(input, candidates) {
|
|
39
|
+
if (!input) return null
|
|
40
|
+
let best = null, bestD = Infinity
|
|
41
|
+
for (const c of candidates) {
|
|
42
|
+
const d = levenshtein(String(input).toLowerCase(), String(c).toLowerCase())
|
|
43
|
+
if (d < bestD) { bestD = d; best = c }
|
|
44
|
+
}
|
|
45
|
+
return (best !== null && bestD <= Math.max(1, Math.floor(String(input).length / 2))) ? best : null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── 变更名路径穿越消毒(F6)──
|
|
49
|
+
// change 名会被 join 进 .sillyspec/changes/<name>/ 当目录用。若含 ../ 或路径分隔符,
|
|
50
|
+
// 会逃出 changes/ 写到任意位置(如 --change ../../etc/x 把 proposal.md 写到 changes 同级之外)。
|
|
51
|
+
// 在 --change 解析边界统一消毒,比在每个 join 点守卫更集中。合法名:字母/数字/._-,禁分隔符与 ..
|
|
52
|
+
export function assertSafeChangeName(name, label = '变更名') {
|
|
53
|
+
if (name == null) return
|
|
54
|
+
const s = String(name)
|
|
55
|
+
if (s === '') throw new Error(`${label}不能为空`)
|
|
56
|
+
// 含路径分隔符 或 父目录穿越段 → 拒绝(Windows 下 \\ 也算)
|
|
57
|
+
if (/[\\/]/.test(s) || /(^|[/\\])\.\.(?=$|[/\\])/.test(s) || s === '..' || s.includes('..')) {
|
|
58
|
+
throw new Error(`${label}「${s}」含路径分隔符或 ..,禁止路径穿越(变更名只能是 .sillyspec/changes/ 下的一个目录名,不能逃出)`)
|
|
59
|
+
}
|
|
60
|
+
if (!/^[\w.\-]+$/.test(s)) {
|
|
61
|
+
throw new Error(`${label}「${s}」含非法字符(仅允许字母、数字、._-)`)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
import { buildExecuteSteps } from '../stages/execute.js'
|
|
66
|
+
import { buildPlanSteps } from '../stages/plan.js'
|
|
67
|
+
import { stageRegistry } from '../stages/index.js'
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 解析 prompt 中的 {{include: <name>}} 占位符:读包内 templates/prompts/<name>.md 注入。
|
|
71
|
+
* 把 stage step prompt 里 self-contained 的大块抽到外部模板,CLI 端组装时注入——
|
|
72
|
+
* agent 收到的仍是自包含 prompt 字符串,无需自己 Read。单次替换(不递归);
|
|
73
|
+
* 模板缺失则保留占位符并 warn,便于发现配置错误。
|
|
74
|
+
*/
|
|
75
|
+
export function resolvePromptIncludes(text) {
|
|
76
|
+
return text.replace(/\{\{include:\s*([\w.-]+)\s*\}\}/g, (match, name) => {
|
|
77
|
+
// shared.js 在 src/run/,templates/prompts 在仓库根 → 上两层
|
|
78
|
+
const tplPath = join(__dirname, '..', '..', 'templates', 'prompts', `${name}.md`)
|
|
79
|
+
if (!existsSync(tplPath)) {
|
|
80
|
+
console.warn(`⚠️ prompt include 模板缺失: ${name} (期望: ${tplPath})`)
|
|
81
|
+
return match
|
|
82
|
+
}
|
|
83
|
+
return readFileSync(tplPath, 'utf8')
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 找 .sillyspec 祖先目录:用户指定 specDir 优先,否则从 cwd 向上找。
|
|
89
|
+
* @param {string} cwd
|
|
90
|
+
* @param {object} [opts]
|
|
91
|
+
* @param {string} [opts.specDir] - 用户指定的 specDir(通过 --spec-dir 或 --spec-root)
|
|
92
|
+
* @returns {string} 规范目录的绝对路径
|
|
93
|
+
*/
|
|
94
|
+
export function resolveSpecDir(cwd, opts = {}) {
|
|
95
|
+
if (opts.specDir) return resolve(opts.specDir)
|
|
96
|
+
let dir = resolve(cwd)
|
|
97
|
+
while (true) {
|
|
98
|
+
const candidate = join(dir, '.sillyspec')
|
|
99
|
+
if (existsSync(candidate)) return candidate
|
|
100
|
+
const parent = dirname(dir)
|
|
101
|
+
if (parent === dir) break
|
|
102
|
+
dir = parent
|
|
103
|
+
}
|
|
104
|
+
return join(resolve(cwd), '.sillyspec')
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 统一查找变更目录(与 progress.js 的变更检测逻辑一致)。
|
|
109
|
+
*/
|
|
110
|
+
export function resolveChangeDir(cwd, progress, specDir = null) {
|
|
111
|
+
const changesDir = join(specDir || resolveSpecDir(cwd), 'changes')
|
|
112
|
+
if (!existsSync(changesDir)) return null
|
|
113
|
+
|
|
114
|
+
// 1. 优先用 currentChange
|
|
115
|
+
if (progress.currentChange) {
|
|
116
|
+
const target = join(changesDir, progress.currentChange)
|
|
117
|
+
if (existsSync(target)) return target
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// 2. fallback:唯一非 archive 目录
|
|
121
|
+
const entries = readdirSync(changesDir, { withFileTypes: true })
|
|
122
|
+
.filter(e => e.isDirectory() && e.name !== 'archive')
|
|
123
|
+
if (entries.length === 1) return join(changesDir, entries[0].name)
|
|
124
|
+
|
|
125
|
+
return null
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 触发 sync(平台模式走自己的链路,跳过;否则 await import sync.js)。
|
|
130
|
+
*/
|
|
131
|
+
export async function triggerSync(cwd, changeName, platformOpts = {}) {
|
|
132
|
+
// 平台模式(SillyHub)走自己的回传链路,不走 CLI 内置 sync
|
|
133
|
+
if (platformOpts?.specRoot || platformOpts?.runtimeRoot) return
|
|
134
|
+
try {
|
|
135
|
+
if (changeName && !existsSync(join(cwd, '.sillyspec', 'changes', changeName))) return
|
|
136
|
+
// shared.js 在 src/run/,sync.js 在 src/ → 退一层
|
|
137
|
+
const syncMod = await import('../sync.js')
|
|
138
|
+
await syncMod.sync(changeName, cwd)
|
|
139
|
+
} catch (e) {
|
|
140
|
+
// sync.js 不存在或同步失败,静默跳过
|
|
141
|
+
console.warn('⚠️ 同步失败:', e.message)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* 审批检查:execute 阶段启动前检查(W6 Step8a 从 run.js 搬入,runStage + runAutoMode 共用)。
|
|
147
|
+
* 平台模式走自己的链路,跳过;否则 await import sync.js。
|
|
148
|
+
* @returns {{ status: string, reason?: string } | null}
|
|
149
|
+
*/
|
|
150
|
+
export async function checkApproval(cwd, changeName, platformOpts = {}) {
|
|
151
|
+
// 平台模式不需要 CLI 内置审批检查
|
|
152
|
+
if (platformOpts?.specRoot || platformOpts?.runtimeRoot) return null
|
|
153
|
+
try {
|
|
154
|
+
// shared.js 在 src/run/,sync.js 在 src/ → 退一层
|
|
155
|
+
const syncMod = await import('../sync.js')
|
|
156
|
+
return await syncMod.checkApproval(changeName, cwd)
|
|
157
|
+
} catch (e) {
|
|
158
|
+
return null
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* 安全执行 git:-c safe.directory per-command(不污染全局 config)+ -C cwd。
|
|
164
|
+
* @returns {{ value: string|null, error: string|null }}
|
|
165
|
+
*/
|
|
166
|
+
export function safeGit(cwd, args) {
|
|
167
|
+
const fullArgs = ['-c', `safe.directory=${cwd}`, '-C', cwd, ...args]
|
|
168
|
+
try {
|
|
169
|
+
const value = execSync(['git', ...fullArgs].join(' '), { encoding: 'utf8', timeout: 5000 }).trim()
|
|
170
|
+
return { value, error: null }
|
|
171
|
+
} catch (e) {
|
|
172
|
+
return { value: null, error: e.message.split('\n')[0] }
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* 解析 git status --porcelain 单行 → 文件路径(去引号/处理 rename/归一化)。
|
|
178
|
+
* 注意:line.slice(3) —— porcelain 行前 2 字符是状态码 + 1 空格,路径从 index 3 开始。
|
|
179
|
+
*/
|
|
180
|
+
export function parsePorcelainPath(line) {
|
|
181
|
+
if (!line) return ''
|
|
182
|
+
let path = line.slice(3).trim()
|
|
183
|
+
if (path.length >= 2 && path.startsWith('"') && path.endsWith('"')) {
|
|
184
|
+
path = path.slice(1, -1).replace(/\\(.)/g, (_, c) => c)
|
|
185
|
+
}
|
|
186
|
+
const arrow = path.indexOf(' -> ')
|
|
187
|
+
if (arrow !== -1) path = path.slice(arrow + 4)
|
|
188
|
+
return path.replace(/\\/g, '/')
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* quick 完成审计:对比 baseline 与实际变更。
|
|
193
|
+
* @returns {{ status: 'safe'|'warning'|'blocked', reasons: string[], changedFiles: string[], newFiles: string[], deletedFiles: string[], baselineHit: string[] }}
|
|
194
|
+
*/
|
|
195
|
+
export async function auditQuickCompletion(cwd, guard, options = {}) {
|
|
196
|
+
const { baselineFiles, allowedFiles = [], allowNew = false, forceBaseline = false } = guard
|
|
197
|
+
const { isConfirm } = options
|
|
198
|
+
const result = { status: 'safe', reasons: [], changedFiles: [], newFiles: [], deletedFiles: [], baselineHit: [] }
|
|
199
|
+
|
|
200
|
+
try {
|
|
201
|
+
const gitStatus = execSync('git status --porcelain', { cwd, encoding: 'utf8', timeout: 10000 })
|
|
202
|
+
// 不对整段 .trim():会削首行前导空格致首文件路径丢首字符(见 parsePorcelainPath 注释)。
|
|
203
|
+
const currentEntries = gitStatus.split('\n').filter(Boolean)
|
|
204
|
+
|
|
205
|
+
const normalizeGitPath = (p) => p.replace(/\\/g, '/')
|
|
206
|
+
// step1 启动时记录的全量脏文件 = 预存改动(非本次 quick 产生)。审计必须排除它们,
|
|
207
|
+
// 否则脏工作区下预存文件持续留在 git status → 命中 baselineFiles → 误判「覆盖 baseline」
|
|
208
|
+
// → 永远 blocked(--force-baseline 也救不回来,因为 status 判定看 baselineHit 数组)。
|
|
209
|
+
// 前缀匹配:baseline 录入时未跟踪目录会被 git 折叠成 `dir/`(带尾斜杠)token,审计时若该
|
|
210
|
+
// 目录下文件被跟踪则展开成文件级 `dir/file`——精确匹配对不上,故尾斜杠 token 按目录前缀放行。
|
|
211
|
+
const normBaseline = (baselineFiles || []).map(f => normalizeGitPath(f))
|
|
212
|
+
const baselineExact = new Set(normBaseline.filter(f => !f.endsWith('/')))
|
|
213
|
+
const baselineDirs = normBaseline.filter(f => f.endsWith('/'))
|
|
214
|
+
const isBaselineFile = (p) => {
|
|
215
|
+
const f = normalizeGitPath(p)
|
|
216
|
+
return baselineExact.has(f) || baselineDirs.some(d => f.startsWith(d))
|
|
217
|
+
}
|
|
218
|
+
// quick 自己没有 .sillyspec/changes/ 目录——该路径下任何文件要么是关联变更(reverse-sync),
|
|
219
|
+
// 要么是并发其他会话的变更工作。多会话并行时别人的 changes/ 不应被本 quick 审计拦截
|
|
220
|
+
// (确定性校验无法区分「并发工作」与「本 quick 偷建变更」,后者这类意图软判定留给 sillyhub)。
|
|
221
|
+
// 故:非关联变更目录整体放行;关联变更的文件仍走正常审计(reverse-sync 可见,需 --force-baseline)。
|
|
222
|
+
const linkedChangeNames = new Set((Array.isArray(guard.linkedChanges) ? guard.linkedChanges : []).map(c => normalizeGitPath(c)))
|
|
223
|
+
const isQuickMetadata = (p) => {
|
|
224
|
+
const file = normalizeGitPath(p)
|
|
225
|
+
if (file.startsWith('.sillyspec/quicklog/')
|
|
226
|
+
|| file.startsWith('.sillyspec/.runtime/')
|
|
227
|
+
|| file === '.sillyspec/knowledge/uncategorized.md'
|
|
228
|
+
|| (/^\.sillyspec\/docs\/[^/]+\/modules\/[^/]+\.md$/.test(file))
|
|
229
|
+
|| (/^\.sillyspec\/docs\/[^/]+\/modules\/_module-map\.yaml$/.test(file))) return true
|
|
230
|
+
// .sillyspec/changes/ 下:bare 折叠 token(git 把全新未跟踪 changes/ 折叠成 `changes/`)
|
|
231
|
+
// 或具体但【非关联】的变更 → 都属并发其他会话工作,放行;关联变更文件仍走审计。
|
|
232
|
+
if (file.startsWith('.sillyspec/changes/')) {
|
|
233
|
+
const m = file.match(/^\.sillyspec\/changes\/([^/]+)(\/|$)/)
|
|
234
|
+
if (!m || !linkedChangeNames.has(m[1])) return true
|
|
235
|
+
}
|
|
236
|
+
return false
|
|
237
|
+
}
|
|
238
|
+
const DANGEROUS_PATTERNS = [
|
|
239
|
+
'package.json',
|
|
240
|
+
'package-lock.json',
|
|
241
|
+
'yarn.lock',
|
|
242
|
+
'pnpm-lock.yaml',
|
|
243
|
+
'.eslintrc',
|
|
244
|
+
'tsconfig.json',
|
|
245
|
+
'src/db.js',
|
|
246
|
+
'src/progress.js',
|
|
247
|
+
'src/run.js',
|
|
248
|
+
'src/stage-contract.js',
|
|
249
|
+
'src/worktree.js',
|
|
250
|
+
'src/worktree-apply.js',
|
|
251
|
+
'src/hooks/',
|
|
252
|
+
]
|
|
253
|
+
|
|
254
|
+
for (const entry of currentEntries) {
|
|
255
|
+
const status = entry.slice(0, 2).trim()
|
|
256
|
+
const file = parsePorcelainPath(entry) // 已去引号/处理 rename/归一化,修正首行丢首字符
|
|
257
|
+
if (!file) continue
|
|
258
|
+
|
|
259
|
+
// 预存脏文件:step1 baseline 已记录,非本次 quick 产生,跳过审计(含折叠目录前缀匹配)
|
|
260
|
+
if (isBaselineFile(file)) continue
|
|
261
|
+
|
|
262
|
+
result.changedFiles.push(file)
|
|
263
|
+
if (status === 'D' || status === ' D') result.deletedFiles.push(file)
|
|
264
|
+
if (status === '??') result.newFiles.push(file)
|
|
265
|
+
|
|
266
|
+
// 检查是否命中 baseline protected files
|
|
267
|
+
if (baselineFiles.includes(file)) {
|
|
268
|
+
result.baselineHit.push(file)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// 检查危险文件(除非 force-baseline)
|
|
272
|
+
if (file.startsWith('.sillyspec/') && !isQuickMetadata(file) && !forceBaseline) {
|
|
273
|
+
result.reasons.push(`危险文件变更: ${file}`)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (DANGEROUS_PATTERNS.some(p => file === p || file.startsWith(p)) && !forceBaseline) {
|
|
277
|
+
result.reasons.push(`危险文件变更: ${file}`)
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// 检查 deleted files
|
|
282
|
+
for (const f of result.deletedFiles) {
|
|
283
|
+
result.reasons.push(`删除文件: ${f}`)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// 检查 baseline hit(除非 force-baseline)
|
|
287
|
+
if (!forceBaseline) {
|
|
288
|
+
for (const f of result.baselineHit) {
|
|
289
|
+
result.reasons.push(`覆盖 baseline 文件: ${f}`)
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// 检查 new files(除非 allow-new)
|
|
294
|
+
if (!allowNew) {
|
|
295
|
+
for (const f of result.newFiles) {
|
|
296
|
+
if (!isQuickMetadata(f)) {
|
|
297
|
+
result.reasons.push(`新增文件(需 --allow-new): ${f}`)
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// 检查 allowedFiles 范围
|
|
303
|
+
if (allowedFiles.length > 0) {
|
|
304
|
+
for (const f of result.changedFiles) {
|
|
305
|
+
if (!allowedFiles.includes(f) && !isQuickMetadata(f)) {
|
|
306
|
+
result.reasons.push(`超出 allowedFiles: ${f}`)
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// 判定结果(force-baseline 降级 baselineHit → 非 blocked;allow-new 降级新增文件 → 非 warning。
|
|
312
|
+
// reasons 文案本就受这两个 flag 控制,但原判定直接看数组长度,致 flag 对 status 失效。)
|
|
313
|
+
if ((!forceBaseline && result.baselineHit.length > 0) || result.deletedFiles.length > 0 || result.reasons.some(r => r.startsWith('危险') || r.startsWith('删除'))) {
|
|
314
|
+
result.status = 'blocked'
|
|
315
|
+
} else if ((!allowNew && result.newFiles.length > 0) || (allowedFiles.length > 0 && result.reasons.some(r => r.startsWith('超出')))) {
|
|
316
|
+
result.status = 'warning'
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// quicklog 存在性检查
|
|
320
|
+
try {
|
|
321
|
+
const quicklogDir = join(cwd, '.sillyspec', 'quicklog')
|
|
322
|
+
if (existsSync(quicklogDir)) {
|
|
323
|
+
const qlFiles = readdirSync(quicklogDir).filter(f => f.endsWith('.md'))
|
|
324
|
+
if (qlFiles.length === 0) {
|
|
325
|
+
result.reasons.push('quicklog 目录为空(无任务记录)')
|
|
326
|
+
if (result.status === 'safe') result.status = 'warning'
|
|
327
|
+
}
|
|
328
|
+
} else {
|
|
329
|
+
result.reasons.push('quicklog 目录不存在(agent 未创建任务记录)')
|
|
330
|
+
if (result.status === 'safe') result.status = 'warning'
|
|
331
|
+
}
|
|
332
|
+
} catch {}
|
|
333
|
+
|
|
334
|
+
// --confirm 模式:展示 diff 并等待确认
|
|
335
|
+
if (isConfirm && (result.status === 'warning' || result.status === 'blocked')) {
|
|
336
|
+
console.log(`\n📋 quick 变更概览:`)
|
|
337
|
+
console.log(` 新增: ${result.newFiles.length}, 修改: ${result.changedFiles.length - result.newFiles.length - result.deletedFiles.length}, 删除: ${result.deletedFiles.length}`)
|
|
338
|
+
if (result.changedFiles.length > 0) {
|
|
339
|
+
console.log(`\n 变更文件:`)
|
|
340
|
+
for (const f of result.changedFiles) {
|
|
341
|
+
const isBaseline = baselineFiles.includes(f)
|
|
342
|
+
const isDangerous = DANGEROUS_PATTERNS.some(p => f.includes(p))
|
|
343
|
+
const marker = isBaseline ? '🔴' : isDangerous ? '⚠️' : ' '
|
|
344
|
+
console.log(` ${marker} ${f}`)
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
console.log(`\n 状态: ${result.status.toUpperCase()}`)
|
|
348
|
+
if (result.reasons.length > 0) {
|
|
349
|
+
console.log(` 原因:`)
|
|
350
|
+
for (const r of result.reasons) {
|
|
351
|
+
console.log(` - ${r}`)
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
console.log(`\n 如确认接受这些变更,重新运行 --done 时带上对应 flag 即可解锁:`)
|
|
355
|
+
console.log(` sillyspec run quick --done --force-baseline --allow-new --change <id> --output "..."`)
|
|
356
|
+
console.log(` (--force-baseline 覆盖受保护/危险文件如 src/run.js;--allow-new 允许新增文件)`)
|
|
357
|
+
console.log(` 或在首个 sillyspec run quick 启动(step 1)时就声明这些 flag,持久化进 guard。`)
|
|
358
|
+
}
|
|
359
|
+
} catch (e) {
|
|
360
|
+
result.reasons.push(`审计失败: ${e.message}`)
|
|
361
|
+
result.status = 'warning'
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return result
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// ── Step 处理辅助(W6 Step7a 从 run.js 搬入:getStageSteps/formatWaitOptions 被 run.js + complete.js 共用)──
|
|
368
|
+
/**
|
|
369
|
+
* 格式化 waitOptions 为人类可读字符串
|
|
370
|
+
*/
|
|
371
|
+
export function formatWaitOptions(raw) {
|
|
372
|
+
if (!raw) return null
|
|
373
|
+
try {
|
|
374
|
+
const parsed = JSON.parse(raw)
|
|
375
|
+
if (Array.isArray(parsed)) return parsed.join(', ')
|
|
376
|
+
return raw
|
|
377
|
+
} catch {
|
|
378
|
+
return raw
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* 获取阶段的步骤定义(execute 需要动态构建)
|
|
385
|
+
*/
|
|
386
|
+
export async function getStageSteps(stageName, cwd, progress, specDir = null) {
|
|
387
|
+
if (stageName === 'execute') {
|
|
388
|
+
const changeDir = resolveChangeDir(cwd, progress, specDir)
|
|
389
|
+
let planFile = null
|
|
390
|
+
let worktreePath = null
|
|
391
|
+
if (changeDir) {
|
|
392
|
+
const p = join(changeDir, 'plan.md')
|
|
393
|
+
if (existsSync(p)) planFile = p
|
|
394
|
+
// 自动检测 worktree 路径,注入 Wave prompt 的 workdir 指令
|
|
395
|
+
// 修复:之前未传 worktreePath 给 buildExecuteSteps,导致 Wave prompt 缺失工作目录指令,
|
|
396
|
+
// 子代理可能把文件写到主工作区而非 worktree 内,破坏隔离。
|
|
397
|
+
try {
|
|
398
|
+
const changeName = basename(changeDir)
|
|
399
|
+
const { WorktreeManager } = await import('../worktree.js')
|
|
400
|
+
const wm = new WorktreeManager({ cwd })
|
|
401
|
+
const meta = wm.getMeta(changeName)
|
|
402
|
+
if (meta?.worktreePath && existsSync(meta.worktreePath)) {
|
|
403
|
+
worktreePath = meta.worktreePath
|
|
404
|
+
}
|
|
405
|
+
} catch {
|
|
406
|
+
// 无 worktree meta 不阻断——可能是首次启动或 in-place 模式
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
return buildExecuteSteps(planFile, { worktreePath })
|
|
410
|
+
}
|
|
411
|
+
if (stageName === 'plan') {
|
|
412
|
+
const changeDir = resolveChangeDir(cwd, progress, specDir)
|
|
413
|
+
return buildPlanSteps(changeDir)
|
|
414
|
+
}
|
|
415
|
+
const def = stageRegistry[stageName]
|
|
416
|
+
return def ? def.steps : null
|
|
417
|
+
}
|
|
418
|
+
|