sillyspec 3.20.5 → 3.20.7
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/CLAUDE.md +23 -0
- package/.claude/skills/sillyspec-archive/SKILL.md +86 -21
- package/.claude/skills/sillyspec-auto/SKILL.md +98 -83
- package/.claude/skills/sillyspec-brainstorm/SKILL.md +78 -44
- package/.claude/skills/sillyspec-doctor/SKILL.md +61 -31
- package/.claude/skills/sillyspec-execute/SKILL.md +90 -30
- package/.claude/skills/sillyspec-explore/SKILL.md +96 -109
- package/.claude/skills/sillyspec-export/SKILL.md +4 -0
- package/.claude/skills/sillyspec-init/SKILL.md +7 -0
- package/.claude/skills/sillyspec-plan/SKILL.md +74 -21
- package/.claude/skills/sillyspec-propose/SKILL.md +61 -21
- package/.claude/skills/sillyspec-quick/SKILL.md +84 -21
- package/.claude/skills/sillyspec-scan/SKILL.md +74 -21
- package/.claude/skills/sillyspec-state/SKILL.md +11 -1
- package/.claude/skills/sillyspec-status/SKILL.md +55 -21
- package/.claude/skills/sillyspec-verify/SKILL.md +82 -21
- package/.claude/skills/sillyspec-workspace/SKILL.md +12 -0
- package/CLAUDE.md +4 -0
- package/docs/sillyspec/file-lifecycle/platform-workflows-sync.md +5 -0
- package/docs/sillyspec/file-lifecycle/stage-artifacts.md +3 -3
- package/docs/sillyspec/file-lifecycle.md +17 -5
- package/docs/worktree-isolation.md +1 -1
- package/package.json +2 -2
- package/src/doctor-diagnostics.js +575 -0
- package/src/hooks/worktree-guard.js +111 -111
- package/src/index.js +158 -86
- package/src/progress.js +68 -0
- package/src/quick-recommend.js +115 -0
- package/src/run.js +272 -51
- package/src/stage-contract.js +23 -5
- package/src/stages/quick.js +36 -26
- package/src/sync.js +14 -3
- package/src/worktree.js +69 -28
- package/test/decision-ref-version.mjs +85 -0
- package/test/platform-scan-p0.test.mjs +18 -7
- package/test/quick-recommend.test.mjs +146 -0
- package/test/stage-contract.test.mjs +5 -3
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* quick-recommend.js — quick 阶段多变更关联推荐打分
|
|
3
|
+
*
|
|
4
|
+
* 多变更场景下,quick 启动前用「脏文件 + 任务描述」双信号,
|
|
5
|
+
* 推测当前 quick 改动最可能归属哪些活跃变更,供交互式多选默认勾选。
|
|
6
|
+
*
|
|
7
|
+
* 纯函数 + 只读文件系统,无副作用,便于单测。
|
|
8
|
+
*/
|
|
9
|
+
import { join } from 'path'
|
|
10
|
+
import { existsSync, readFileSync } from 'fs'
|
|
11
|
+
import { parseFileChangeList } from './change-list.js'
|
|
12
|
+
|
|
13
|
+
function escapeRegex(s) {
|
|
14
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 单个 token 是否命中上下文(参考 knowledge-match.js 的 keywordMatchesContext 思路)。
|
|
19
|
+
* - 过短(<2 字符)视为噪音
|
|
20
|
+
* - 非 ASCII(中文等)用子串匹配
|
|
21
|
+
* - ASCII 用词边界匹配,避免 "DB" 误命中 "dashboard"
|
|
22
|
+
*/
|
|
23
|
+
function tokenInContext(token, contextLower) {
|
|
24
|
+
const t = token.toLowerCase().trim()
|
|
25
|
+
if (t.length < 2) return false
|
|
26
|
+
if (/[^\x00-\x7f]/.test(t)) {
|
|
27
|
+
// 中文(等非 ASCII):用 2-gram 重叠匹配。
|
|
28
|
+
// 避免整串子串匹配漏召回(如 token「修解析器」与 context「修复解析器」整串不等,
|
|
29
|
+
// 但共享 2-gram「解析」)。2-gram 足以抑制单字噪音,又能覆盖词形变化。
|
|
30
|
+
for (let i = 0; i <= t.length - 2; i++) {
|
|
31
|
+
if (contextLower.includes(t.slice(i, i + 2))) return true
|
|
32
|
+
}
|
|
33
|
+
return false
|
|
34
|
+
}
|
|
35
|
+
return new RegExp(`(^|[^a-z0-9])${escapeRegex(t)}([^a-z0-9]|$)`).test(contextLower)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 把任务描述切成用于匹配的 token(按空白/标点切分,过滤空串)。
|
|
40
|
+
* 保留中英文混排,不做分词——推荐只求召回,精确度靠用户最终勾选把关。
|
|
41
|
+
*/
|
|
42
|
+
function tokenizeDescription(desc) {
|
|
43
|
+
if (!desc) return []
|
|
44
|
+
return desc
|
|
45
|
+
.split(/[\s,,。、;;::()()\[\]{}"'`/\\|]+/)
|
|
46
|
+
.map(t => t.trim())
|
|
47
|
+
.filter(Boolean)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 读取变更 proposal.md 的全文(小写),用于任务描述信号匹配;缺失返回空串。 */
|
|
51
|
+
function readProposalContext(specDir, changeName) {
|
|
52
|
+
const p = join(specDir, 'changes', changeName, 'proposal.md')
|
|
53
|
+
if (!existsSync(p)) return ''
|
|
54
|
+
try {
|
|
55
|
+
return readFileSync(p, 'utf8').toLowerCase()
|
|
56
|
+
} catch {
|
|
57
|
+
return ''
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 判断单个脏文件是否命中 design.md 声明的文件清单。
|
|
63
|
+
* 支持精确匹配 + 目录前缀匹配(design 可能写目录、脏文件是目录下文件,或反之)。
|
|
64
|
+
*/
|
|
65
|
+
function fileHitsList(dirtyFile, fileList) {
|
|
66
|
+
if (fileList.has(dirtyFile)) return true
|
|
67
|
+
for (const declared of fileList) {
|
|
68
|
+
if (declared === dirtyFile) continue
|
|
69
|
+
if (dirtyFile.startsWith(declared + '/') || declared.startsWith(dirtyFile + '/')) return true
|
|
70
|
+
}
|
|
71
|
+
return false
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 推荐关联变更。
|
|
76
|
+
* @param {{ activeChanges: string[], specDir: string, baselineFiles?: string[], quickFiles?: string[], taskDescription?: string }} opts
|
|
77
|
+
* @returns {{ name: string, score: number, reasons: string[] }[]} 按 score 降序、同名升序
|
|
78
|
+
*/
|
|
79
|
+
export function recommendChanges({
|
|
80
|
+
activeChanges,
|
|
81
|
+
specDir,
|
|
82
|
+
baselineFiles = [],
|
|
83
|
+
quickFiles = [],
|
|
84
|
+
taskDescription = '',
|
|
85
|
+
}) {
|
|
86
|
+
if (!activeChanges || activeChanges.length === 0) return []
|
|
87
|
+
|
|
88
|
+
const dirtyFiles = [...new Set([...baselineFiles, ...quickFiles])]
|
|
89
|
+
.filter(f => f && !f.startsWith('.sillyspec/'))
|
|
90
|
+
const descTokens = [...new Set(tokenizeDescription(taskDescription))]
|
|
91
|
+
|
|
92
|
+
const results = activeChanges.map(name => {
|
|
93
|
+
const reasons = []
|
|
94
|
+
|
|
95
|
+
// 信号 1:脏文件命中 design.md 文件变更清单
|
|
96
|
+
const fileList = parseFileChangeList(join(specDir, 'changes', name, 'design.md'))
|
|
97
|
+
if (fileList.size > 0 && dirtyFiles.length > 0) {
|
|
98
|
+
for (const f of dirtyFiles) {
|
|
99
|
+
if (fileHitsList(f, fileList)) reasons.push(`脏文件命中: ${f}`)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 信号 2:任务描述 token 命中 proposal.md
|
|
104
|
+
const proposalCtx = readProposalContext(specDir, name)
|
|
105
|
+
if (proposalCtx && descTokens.length > 0) {
|
|
106
|
+
for (const t of descTokens) {
|
|
107
|
+
if (tokenInContext(t, proposalCtx)) reasons.push(`任务描述命中: ${t}`)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return { name, score: reasons.length, reasons }
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
return results.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name))
|
|
115
|
+
}
|
package/src/run.js
CHANGED
|
@@ -10,6 +10,7 @@ import { createRequire } from 'module'
|
|
|
10
10
|
const require = createRequire(import.meta.url)
|
|
11
11
|
import { ProgressManager } from './progress.js'
|
|
12
12
|
import { SCAN_STATUS, POINTER_STATUS, isPointerCorrupted } from './constants.js'
|
|
13
|
+
import { checkbox } from '@inquirer/prompts'
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* 清洗项目名:只保留 ASCII 字母/数字/横线/下划线/点,过滤中文和特殊字符。
|
|
@@ -703,6 +704,20 @@ async function outputStep(stageName, stepIndex, steps, cwd, changeName, dbProjec
|
|
|
703
704
|
if (changeName && promptText.includes('<change-name>')) {
|
|
704
705
|
promptText = promptText.replace(/<change-name>/g, changeName)
|
|
705
706
|
}
|
|
707
|
+
// 替换 <linked-changes> 占位符(quick 阶段:从 .runtime/quick-guard.json 读关联变更)
|
|
708
|
+
if (promptText.includes('<linked-changes>')) {
|
|
709
|
+
const specBaseLc = platformOpts?.specRoot || join(cwd, '.sillyspec')
|
|
710
|
+
let linkedChanges = []
|
|
711
|
+
try {
|
|
712
|
+
const guardFile = join(specBaseLc, '.runtime', 'quick-guard.json')
|
|
713
|
+
if (existsSync(guardFile)) {
|
|
714
|
+
const guard = JSON.parse(readFileSync(guardFile, 'utf8'))
|
|
715
|
+
linkedChanges = Array.isArray(guard.linkedChanges) ? guard.linkedChanges : []
|
|
716
|
+
}
|
|
717
|
+
} catch {}
|
|
718
|
+
const display = linkedChanges.length > 0 ? linkedChanges.join(', ') : '(无)'
|
|
719
|
+
promptText = promptText.replace(/<linked-changes>/g, display)
|
|
720
|
+
}
|
|
706
721
|
// 平台模式:注入路径覆盖指令
|
|
707
722
|
if (platformOpts?.specRoot || platformOpts?.runtimeRoot) {
|
|
708
723
|
const projectName = dbProjectName || basename(cwd)
|
|
@@ -1276,30 +1291,39 @@ export async function runCommand(args, cwd, specDir = null) {
|
|
|
1276
1291
|
// runCommand 后续所有 .sillyspec/ 操作必须用 specBase
|
|
1277
1292
|
const specBase = platformOpts.specRoot || join(cwd, '.sillyspec')
|
|
1278
1293
|
|
|
1279
|
-
//
|
|
1294
|
+
// 平台模式:首次接入时清理旧版本残留的 cwd/.sillyspec/(防止源码污染)。
|
|
1280
1295
|
// ⚠️ 同 init.js:必须保护真实资产(changes/、projects/、sillyspec.db)。
|
|
1296
|
+
// 只在「首次」执行一次——用 cwd 下的 .sillyspec-platform-cleaned 标记文件记录已处理,
|
|
1297
|
+
// 后续每次 run 直接跳过,避免重复检查 + 红叉噪声(此清理不阻塞流程、不动真实资产)。
|
|
1281
1298
|
if (platformOpts.specRoot) {
|
|
1282
1299
|
const legacyDir = join(cwd, '.sillyspec')
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1300
|
+
const cleanedMarker = join(cwd, '.sillyspec-platform-cleaned')
|
|
1301
|
+
if (!existsSync(cleanedMarker)) {
|
|
1302
|
+
if (existsSync(legacyDir)) {
|
|
1303
|
+
let hasChanges = false;
|
|
1304
|
+
try {
|
|
1305
|
+
const cd = join(legacyDir, 'changes');
|
|
1306
|
+
if (existsSync(cd)) hasChanges = readdirSync(cd).length > 0;
|
|
1307
|
+
} catch {}
|
|
1308
|
+
let hasProjects = false;
|
|
1309
|
+
try {
|
|
1310
|
+
const pd = join(legacyDir, 'projects');
|
|
1311
|
+
if (existsSync(pd)) hasProjects = readdirSync(pd).length > 0;
|
|
1312
|
+
} catch {}
|
|
1313
|
+
const hasDb = existsSync(join(legacyDir, 'sillyspec.db'));
|
|
1314
|
+
|
|
1315
|
+
if (hasChanges || hasProjects || hasDb) {
|
|
1316
|
+
// 真实资产存在:只清运行时残留(白名单保留 worktrees/进度),不整删
|
|
1317
|
+
const { cleanupRuntimeResidue } = await import('./init.js')
|
|
1318
|
+
cleanupRuntimeResidue(legacyDir);
|
|
1319
|
+
console.log('ℹ️ [sillyspec] 源码目录 .sillyspec/ 含真实资产,已跳过整删,仅清理运行时残留(仅本次首次,后续不再提示)。');
|
|
1320
|
+
} else {
|
|
1321
|
+
try { rmSync(legacyDir, { recursive: true, force: true }) } catch {}
|
|
1322
|
+
if (!existsSync(legacyDir)) console.log('🧹 已清理旧版本残留的源码 .sillyspec/ 目录');
|
|
1323
|
+
}
|
|
1302
1324
|
}
|
|
1325
|
+
// 标记本 cwd 已做平台残留清理决策,后续 run 跳过(即使之后 .sillyspec/ 被重建也不误清)
|
|
1326
|
+
try { writeFileSync(cleanedMarker, new Date().toISOString() + '\n') } catch {}
|
|
1303
1327
|
}
|
|
1304
1328
|
}
|
|
1305
1329
|
|
|
@@ -1317,11 +1341,38 @@ export async function runCommand(args, cwd, specDir = null) {
|
|
|
1317
1341
|
inputText = flags[inputIdx + 1]
|
|
1318
1342
|
}
|
|
1319
1343
|
|
|
1320
|
-
// 解析 --
|
|
1344
|
+
// 解析 --linked-changes <a,b|none>(quick 专用:显式声明关联变更,CI/脚本友好)
|
|
1345
|
+
// 与 --change 解耦:--linked-changes 语义清晰(关联变更),不与「指定变更名」混淆。
|
|
1346
|
+
// null = 未指定(走持久化/交互/兼容回退);[] = 显式 none(不关联);[...] = 显式列表
|
|
1347
|
+
let explicitLinked = null
|
|
1348
|
+
const linkedIdx = flags.indexOf('--linked-changes')
|
|
1349
|
+
if (linkedIdx !== -1 && flags[linkedIdx + 1]) {
|
|
1350
|
+
const v = flags[linkedIdx + 1].trim()
|
|
1351
|
+
explicitLinked = v.toLowerCase() === 'none' ? [] : v.split(',').map(s => s.trim()).filter(Boolean)
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
// 解析 --change <name>(quick 阶段向后兼容:逗号分隔作为「关联变更」;
|
|
1355
|
+
// 历史写法,语义与「指定变更名」冲突,新用法建议改用 --linked-changes)
|
|
1321
1356
|
let changeName = null
|
|
1357
|
+
let linkedChanges = []
|
|
1322
1358
|
const changeIdx = flags.indexOf('--change')
|
|
1323
1359
|
if (changeIdx !== -1 && flags[changeIdx + 1]) {
|
|
1324
1360
|
changeName = flags[changeIdx + 1]
|
|
1361
|
+
if (stageName === 'quick') {
|
|
1362
|
+
linkedChanges = changeName.split(',').map(s => s.trim()).filter(Boolean)
|
|
1363
|
+
changeName = null
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
// --linked-changes 优先于 --change(显式 > 隐式兼容)
|
|
1367
|
+
if (explicitLinked !== null) {
|
|
1368
|
+
linkedChanges = explicitLinked
|
|
1369
|
+
}
|
|
1370
|
+
// quick 的 progress 键固定走 'default':归属语义已由 linkedChanges 承担(→ quick-guard.json
|
|
1371
|
+
// → <linked-changes> 占位符注入 prompt)。progress 不能走 pm.read(cwd, null) 的自动检测——
|
|
1372
|
+
// 多活跃 change 项目里 quick 步骤进度会随活跃 change 增减在各 change/default 间漂移,
|
|
1373
|
+
// 表现为 --done 时「回退」到更早步骤。固定 default 后无论是否带 --change 都稳定。
|
|
1374
|
+
if (stageName === 'quick') {
|
|
1375
|
+
changeName = 'default'
|
|
1325
1376
|
}
|
|
1326
1377
|
|
|
1327
1378
|
// 解析 --files a.js,b.js(quick 专用:显式声明 allowedFiles)
|
|
@@ -1340,7 +1391,7 @@ export async function runCommand(args, cwd, specDir = null) {
|
|
|
1340
1391
|
'--done', '--skip', '--status', '--reset', '--confirm', '--skip-approval',
|
|
1341
1392
|
'--wait', '--continue', '--non-interactive', '--interactive',
|
|
1342
1393
|
'--reason', '--options', '--answer', '--confirm-mode',
|
|
1343
|
-
'--output', '--input', '--change',
|
|
1394
|
+
'--output', '--input', '--change', '--linked-changes',
|
|
1344
1395
|
'--spec-dir', '--spec-root', '--runtime-root', '--workspace-id', '--scan-run-id',
|
|
1345
1396
|
'--files', '--allow-new', '--force-baseline', '--force-rescan',
|
|
1346
1397
|
'--json', '--dir', '--help',
|
|
@@ -1363,6 +1414,31 @@ export async function runCommand(args, cwd, specDir = null) {
|
|
|
1363
1414
|
// scan 元数据追踪(存储在 stageData.scanMeta 中,completeStep 通过 progress 访问)
|
|
1364
1415
|
|
|
1365
1416
|
const pm = new ProgressManager({ specDir: specRoot })
|
|
1417
|
+
|
|
1418
|
+
// quick 阶段:确定关联变更
|
|
1419
|
+
// 优先级:--linked-changes / --change 显式 > 已持久化 quick-guard.json(--done 复用)> 交互式 > 非交互 fallback
|
|
1420
|
+
// 关键:--done 收尾时复用首次 run 持久化的 linkedChanges,不在管道/CI 下重复弹交互 prompt。
|
|
1421
|
+
// explicitLinked === null 且未传 --change 时才进入此分支(显式 --linked-changes none 不应触发交互)。
|
|
1422
|
+
if (stageName === 'quick' && explicitLinked === null && linkedChanges.length === 0) {
|
|
1423
|
+
const guardFile = join(specRoot, '.runtime', 'quick-guard.json')
|
|
1424
|
+
let persistedLinked = null
|
|
1425
|
+
try {
|
|
1426
|
+
if (existsSync(guardFile)) {
|
|
1427
|
+
const g = JSON.parse(readFileSync(guardFile, 'utf8'))
|
|
1428
|
+
if (Array.isArray(g.linkedChanges)) persistedLinked = g.linkedChanges
|
|
1429
|
+
}
|
|
1430
|
+
} catch {}
|
|
1431
|
+
if (persistedLinked) {
|
|
1432
|
+
linkedChanges = persistedLinked
|
|
1433
|
+
} else {
|
|
1434
|
+
linkedChanges = await resolveQuickLinkedChanges({
|
|
1435
|
+
pm, cwd, specDir: specRoot, quickFiles,
|
|
1436
|
+
taskDescription: inputText || '',
|
|
1437
|
+
nonInteractive: isNonInteractive,
|
|
1438
|
+
})
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1366
1442
|
let progress = await pm.read(cwd, changeName)
|
|
1367
1443
|
|
|
1368
1444
|
if (!progress) {
|
|
@@ -1382,6 +1458,12 @@ export async function runCommand(args, cwd, specDir = null) {
|
|
|
1382
1458
|
} else {
|
|
1383
1459
|
// brainstorm 作为流程入口,自动生成变更名并初始化
|
|
1384
1460
|
if (stageName === 'brainstorm') {
|
|
1461
|
+
if (isDone) {
|
|
1462
|
+
console.error('❌ --done 找不到变更进度数据。')
|
|
1463
|
+
console.error(' 请用 --change <变更名> 指定要完成的变更,')
|
|
1464
|
+
console.error(' 或先运行 sillyspec run brainstorm --change <变更名> 初始化。')
|
|
1465
|
+
process.exit(1)
|
|
1466
|
+
}
|
|
1385
1467
|
const date = new Date().toISOString().slice(0, 10)
|
|
1386
1468
|
const autoName = `${date}-new-change`
|
|
1387
1469
|
console.log(`🔄 自动创建变更:${autoName}`)
|
|
@@ -1523,7 +1605,7 @@ export async function runCommand(args, cwd, specDir = null) {
|
|
|
1523
1605
|
}
|
|
1524
1606
|
|
|
1525
1607
|
// 默认:输出当前步骤
|
|
1526
|
-
return await runStage(pm, progress, stageName, cwd, effectiveChange, isSkipApproval, platformOpts, { quickFiles, isAllowNew, isForceBaseline, isForceRescan })
|
|
1608
|
+
return await runStage(pm, progress, stageName, cwd, effectiveChange, isSkipApproval, platformOpts, { quickFiles, isAllowNew, isForceBaseline, isForceRescan, linkedChanges })
|
|
1527
1609
|
}
|
|
1528
1610
|
|
|
1529
1611
|
/**
|
|
@@ -1538,6 +1620,73 @@ function resolveChangeNameAuto(cwd, specDir = null) {
|
|
|
1538
1620
|
return null
|
|
1539
1621
|
}
|
|
1540
1622
|
|
|
1623
|
+
/**
|
|
1624
|
+
* quick 阶段多变更交互式选择关联变更。
|
|
1625
|
+
* - 0 活跃变更 → [](仅记 QUICKLOG,不关联)
|
|
1626
|
+
* - 1 活跃变更 → 默认关联它(保持现状友好,不弹交互)
|
|
1627
|
+
* - ≥2 活跃变更 + 交互 → checkbox 多选(推荐项默认勾,空选 = 不关联)
|
|
1628
|
+
* - ≥2 活跃变更 + 非交互 → [](不关联)+ 提示用 --change a,b
|
|
1629
|
+
*/
|
|
1630
|
+
async function resolveQuickLinkedChanges({ pm, cwd, specDir, quickFiles, taskDescription, nonInteractive }) {
|
|
1631
|
+
let activeChanges = []
|
|
1632
|
+
try {
|
|
1633
|
+
activeChanges = await pm.listChanges(cwd)
|
|
1634
|
+
} catch {
|
|
1635
|
+
activeChanges = []
|
|
1636
|
+
}
|
|
1637
|
+
if (activeChanges.length === 0) return []
|
|
1638
|
+
if (activeChanges.length === 1) return [activeChanges[0]]
|
|
1639
|
+
|
|
1640
|
+
if (nonInteractive || !process.stdin.isTTY) {
|
|
1641
|
+
console.log('💡 非交互环境,已默认不关联变更;如需关联请用 --change a,b')
|
|
1642
|
+
return []
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
// 脏文件(推荐信号之一)
|
|
1646
|
+
let baselineFiles = []
|
|
1647
|
+
try {
|
|
1648
|
+
const { execSync } = await import('child_process')
|
|
1649
|
+
const gs = execSync('git status --porcelain', { cwd, encoding: 'utf8', timeout: 10000 })
|
|
1650
|
+
baselineFiles = gs.trim().split('\n').filter(Boolean)
|
|
1651
|
+
.map(l => l.slice(3).trim())
|
|
1652
|
+
.filter(f => !f.startsWith('.sillyspec/'))
|
|
1653
|
+
} catch {}
|
|
1654
|
+
|
|
1655
|
+
// 推荐打分(脏文件 + 任务描述双信号)
|
|
1656
|
+
let recommendations = []
|
|
1657
|
+
try {
|
|
1658
|
+
const { recommendChanges } = await import('./quick-recommend.js')
|
|
1659
|
+
recommendations = recommendChanges({ activeChanges, specDir, baselineFiles, quickFiles, taskDescription })
|
|
1660
|
+
} catch {
|
|
1661
|
+
recommendations = activeChanges.map(name => ({ name, score: 0, reasons: [] }))
|
|
1662
|
+
}
|
|
1663
|
+
const scoreMap = new Map(recommendations.map(r => [r.name, r.score]))
|
|
1664
|
+
const reasonMap = new Map(recommendations.map(r => [r.name, r.reasons]))
|
|
1665
|
+
const recommendedSet = new Set(recommendations.filter(r => r.score > 0).map(r => r.name))
|
|
1666
|
+
|
|
1667
|
+
// 按推荐分降序展示
|
|
1668
|
+
const ordered = [...activeChanges].sort((a, b) =>
|
|
1669
|
+
(scoreMap.get(b) || 0) - (scoreMap.get(a) || 0) || a.localeCompare(b))
|
|
1670
|
+
|
|
1671
|
+
const choices = ordered.map(name => {
|
|
1672
|
+
const reasons = reasonMap.get(name) || []
|
|
1673
|
+
const isRec = recommendedSet.has(name)
|
|
1674
|
+
return {
|
|
1675
|
+
name: `${isRec ? '⭐ ' : ' '}${name}`,
|
|
1676
|
+
value: name,
|
|
1677
|
+
description: reasons.length > 0 ? reasons.join(';') : '无推荐信号',
|
|
1678
|
+
checked: isRec,
|
|
1679
|
+
}
|
|
1680
|
+
})
|
|
1681
|
+
|
|
1682
|
+
console.log('🔗 检测到多个活跃变更,选择本次 quick 关联哪些(可多选;不勾选任何项 = 仅记 QUICKLOG,不关联变更)')
|
|
1683
|
+
if (recommendedSet.size > 0) {
|
|
1684
|
+
console.log(' ⭐ = 基于脏文件/任务描述推荐,已默认勾选')
|
|
1685
|
+
}
|
|
1686
|
+
const selected = await checkbox({ message: '关联变更(空格切换,回车确认)', choices })
|
|
1687
|
+
return selected
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1541
1690
|
async function runStage(pm, progress, stageName, cwd, changeName, skipApproval = false, platformOpts = {}, quickOpts = {}) {
|
|
1542
1691
|
const specBase = platformOpts.specRoot || join(cwd, '.sillyspec')
|
|
1543
1692
|
// 状态转换校验
|
|
@@ -1583,7 +1732,10 @@ async function runStage(pm, progress, stageName, cwd, changeName, skipApproval =
|
|
|
1583
1732
|
console.log(`🔗 worktree 已创建: ${result.worktreePath} (分支: ${result.branch}, 模式: ${result.mode})`)
|
|
1584
1733
|
} catch (e) {
|
|
1585
1734
|
console.error(`❌ worktree 创建失败: ${e.message}`)
|
|
1586
|
-
console.error(`
|
|
1735
|
+
console.error(` 修复建议:`)
|
|
1736
|
+
console.error(` 1. 运行 sillyspec worktree doctor --fix 检查并修复 worktree 状态`)
|
|
1737
|
+
console.error(` 2. 或手动清理残留:git worktree prune && git branch -D sillyspec/${effectiveChange}`)
|
|
1738
|
+
console.error(` 3. 必要时删除残留目录 .sillyspec/.runtime/worktrees/${effectiveChange}/ 后重试`)
|
|
1587
1739
|
process.exit(1)
|
|
1588
1740
|
}
|
|
1589
1741
|
}
|
|
@@ -1730,6 +1882,7 @@ async function runStage(pm, progress, stageName, cwd, changeName, skipApproval =
|
|
|
1730
1882
|
allowedFiles,
|
|
1731
1883
|
allowNew,
|
|
1732
1884
|
forceBaseline,
|
|
1885
|
+
linkedChanges: Array.isArray(quickOpts?.linkedChanges) ? quickOpts.linkedChanges : [],
|
|
1733
1886
|
startedAt: new Date().toISOString(),
|
|
1734
1887
|
}
|
|
1735
1888
|
// 写入 quick-guard.json 供 worktree-guard hook 读取
|
|
@@ -1904,6 +2057,13 @@ async function archiveChangeDirectory(pm, cwd, progress, specBase) {
|
|
|
1904
2057
|
console.error(`❌ 归档失败:源目录不存在 ${srcDir}`)
|
|
1905
2058
|
process.exit(1)
|
|
1906
2059
|
}
|
|
2060
|
+
// 移动前硬校验:变更包必须含 plan.md,否则不该归档。
|
|
2061
|
+
// 在移动前阻断(而非移动后),目录尚未动,用户可直接修复后重试。
|
|
2062
|
+
if (!existsSync(join(srcDir, 'plan.md'))) {
|
|
2063
|
+
console.error(`❌ 归档失败:变更目录缺少 plan.md(${srcDir})`)
|
|
2064
|
+
console.error(` plan.md 是归档的必需产物。请先补全 plan 阶段产出再归档。`)
|
|
2065
|
+
process.exit(1)
|
|
2066
|
+
}
|
|
1907
2067
|
if (existsSync(destDir)) {
|
|
1908
2068
|
console.error(`❌ 归档失败:目标目录已存在 ${destDir}`)
|
|
1909
2069
|
process.exit(1)
|
|
@@ -1917,7 +2077,32 @@ async function archiveChangeDirectory(pm, cwd, progress, specBase) {
|
|
|
1917
2077
|
}
|
|
1918
2078
|
|
|
1919
2079
|
await pm.unregisterChange(cwd, archiveChangeName)
|
|
2080
|
+
|
|
2081
|
+
// 归档时清理可能残留的 worktree(execute 自动清理未走到 / 有未 apply 变更被遗弃)。
|
|
2082
|
+
// 安全策略:有未 apply 变更时保留 worktree 并警告,避免误删用户未应用的代码。
|
|
2083
|
+
try {
|
|
2084
|
+
const { WorktreeManager } = await import('./worktree.js')
|
|
2085
|
+
const wm = new WorktreeManager({ cwd })
|
|
2086
|
+
const meta = wm.getMeta(archiveChangeName)
|
|
2087
|
+
if (meta) {
|
|
2088
|
+
const check = meta.mode !== 'in-place-fallback' ? wm.hasUnappliedChanges(archiveChangeName) : { hasChanges: false }
|
|
2089
|
+
if (check.hasChanges) {
|
|
2090
|
+
console.warn(`⚠️ 归档时 worktree 仍有 ${check.changedFiles.length} 个未 apply 变更,保留 worktree`)
|
|
2091
|
+
console.warn(` 确认不需要后手动清理: sillyspec worktree cleanup ${archiveChangeName} --force`)
|
|
2092
|
+
} else {
|
|
2093
|
+
const cleanResult = wm.cleanup(archiveChangeName)
|
|
2094
|
+
if (cleanResult.residual?.length > 0) {
|
|
2095
|
+
console.warn(`⚠️ 归档 worktree 清理残留: ${cleanResult.residual.join('; ')}`)
|
|
2096
|
+
console.warn(` 手动处理: sillyspec worktree cleanup ${archiveChangeName} --force`)
|
|
2097
|
+
}
|
|
2098
|
+
}
|
|
2099
|
+
}
|
|
2100
|
+
} catch (e) {
|
|
2101
|
+
console.warn(`⚠️ 归档 worktree 清理失败(不阻断归档): ${e.message}`)
|
|
2102
|
+
}
|
|
2103
|
+
|
|
1920
2104
|
console.log(`📦 已归档:${archiveChangeName} → archive/${date}-${archiveChangeName}/`)
|
|
2105
|
+
return destDir
|
|
1921
2106
|
}
|
|
1922
2107
|
|
|
1923
2108
|
// ── Wait Step ──
|
|
@@ -2129,9 +2314,8 @@ async function continueStep(pm, progress, stageName, cwd, answer, options = {})
|
|
|
2129
2314
|
console.log('🔗 Worktree: n/a (no meta)');
|
|
2130
2315
|
} else if (meta.mode === 'native-worktree') {
|
|
2131
2316
|
console.log('🔗 Worktree: kept (外部隔离环境)');
|
|
2132
|
-
} else if (meta.mode === 'in-place-fallback') {
|
|
2133
|
-
console.log('🔗 Worktree: n/a (in-place 模式)');
|
|
2134
2317
|
} else {
|
|
2318
|
+
// in-place 模式不再短路:cleanup 现在能安全处理 in-place(只清 meta,不碰主工作区)
|
|
2135
2319
|
const check = wm.hasUnappliedChanges(changeName);
|
|
2136
2320
|
if (check.hasChanges) {
|
|
2137
2321
|
console.log(`🔗 Worktree: pending apply (${check.changedFiles.length} 个未应用变更)`);
|
|
@@ -2139,6 +2323,10 @@ async function continueStep(pm, progress, stageName, cwd, answer, options = {})
|
|
|
2139
2323
|
} else {
|
|
2140
2324
|
const cleanResult = wm.cleanup(changeName);
|
|
2141
2325
|
console.log(`🔗 Worktree: ${cleanResult.result}`);
|
|
2326
|
+
if (cleanResult.residual?.length > 0) {
|
|
2327
|
+
console.warn(` ⚠️ 清理残留: ${cleanResult.residual.join('; ')}`);
|
|
2328
|
+
console.warn(` 手动处理: sillyspec worktree cleanup ${changeName} --force`);
|
|
2329
|
+
}
|
|
2142
2330
|
}
|
|
2143
2331
|
}
|
|
2144
2332
|
} catch (e) {
|
|
@@ -2255,6 +2443,7 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
|
|
|
2255
2443
|
const specBase = platformOpts.specRoot || join(cwd, '.sillyspec')
|
|
2256
2444
|
const stageData = progress.stages[stageName]
|
|
2257
2445
|
const scanProfile = stageData?.scanProfile || null
|
|
2446
|
+
let archivedDir = null // archive step「确认归档」实际归档目录(archiveChangeDirectory 返回)
|
|
2258
2447
|
|
|
2259
2448
|
// ── WAIT MARKER 硬校验 ──
|
|
2260
2449
|
// 如果 output 包含等待标记,拒绝 --done 推进
|
|
@@ -2342,23 +2531,22 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
|
|
|
2342
2531
|
console.log('⚠️ 请添加 --confirm 确认归档,例如:sillyspec run archive --done --confirm --output "确认归档"')
|
|
2343
2532
|
return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
|
|
2344
2533
|
}
|
|
2345
|
-
await archiveChangeDirectory(pm, cwd, progress, specBase)
|
|
2534
|
+
archivedDir = await archiveChangeDirectory(pm, cwd, progress, specBase)
|
|
2346
2535
|
}
|
|
2347
2536
|
|
|
2348
2537
|
// archive "确认归档" 步骤完成后,校验归档完整性
|
|
2349
2538
|
if (stageName === 'archive' && steps[currentIdx]?.name === '确认归档' && confirm) {
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
console.warn(` - ${w}`)
|
|
2539
|
+
// contracts.archive.validators 为空数组(两个 validator 生效窗口互斥,注册为阶段级会误报),
|
|
2540
|
+
// 故 runValidators('archive') 是空跑。这里直接对实际归档目录做内容完整性校验。
|
|
2541
|
+
// plan.md 已在 archiveChangeDirectory 移动前硬校验阻断,此处只查推荐文档。
|
|
2542
|
+
if (archivedDir && existsSync(archivedDir)) {
|
|
2543
|
+
const recommendedDocs = ['design.md', 'module-impact.md']
|
|
2544
|
+
const missingRecommended = recommendedDocs.filter(d => !existsSync(join(archivedDir, d)))
|
|
2545
|
+
if (missingRecommended.length > 0) {
|
|
2546
|
+
console.warn(`\n⚠️ 归档校验警告:归档目录缺少推荐文档`)
|
|
2547
|
+
for (const d of missingRecommended) console.warn(` - ${d}(${archivedDir})`)
|
|
2548
|
+
} else {
|
|
2549
|
+
console.log(`\n✅ 归档校验通过:核心文档齐全`)
|
|
2362
2550
|
}
|
|
2363
2551
|
}
|
|
2364
2552
|
}
|
|
@@ -2816,10 +3004,11 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
|
|
|
2816
3004
|
// summary 失败不影响主流程
|
|
2817
3005
|
console.log('\n👉 下一步:sillyspec run verify(验证通过后才能归档)')
|
|
2818
3006
|
}
|
|
2819
|
-
} else if (stageName === 'verify') {
|
|
2820
|
-
console.log('\n👉 下一步:sillyspec run archive(验证通过,可以归档了)')
|
|
2821
3007
|
} else if (stageName === 'archive') {
|
|
2822
3008
|
console.log('\n👉 归档完成!现在可以提交了:git commit -m "..."')
|
|
3009
|
+
} else if (stageName === 'verify') {
|
|
3010
|
+
// verify 的"验证通过"提示延后到下方 validator 通过后才打印,
|
|
3011
|
+
// 避免校验失败(FAIL / 缺 verify-result.md)时仍声称"验证通过可以归档"。
|
|
2823
3012
|
} else {
|
|
2824
3013
|
console.log(`\n下一步由你决定:sillyspec run <stage>(brainstorm/plan/execute/verify/archive 等)`)
|
|
2825
3014
|
}
|
|
@@ -2834,6 +3023,13 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
|
|
|
2834
3023
|
console.error(` - ${err}`)
|
|
2835
3024
|
}
|
|
2836
3025
|
console.error(`\n 提示:修复缺失产物后重新运行此步骤,或使用 --skip-approval 跳过校验`)
|
|
3026
|
+
// 产物校验失败必须阻断完成 —— 否则 validator 形同虚设,
|
|
3027
|
+
// verify 会带着 FAIL/缺 verify-result.md 被 ✅ 标记完成(历史教训)。
|
|
3028
|
+
// plan/execute 的专项契约校验(下方)在产物齐全后才需要继续跑,故此处先 return。
|
|
3029
|
+
progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
|
|
3030
|
+
await pm._write(cwd, progress, changeName)
|
|
3031
|
+
triggerSync(cwd, changeName, platformOpts)
|
|
3032
|
+
return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
|
|
2837
3033
|
}
|
|
2838
3034
|
if (contractResult.warnings.length > 0) {
|
|
2839
3035
|
console.warn(`\n⚠️ 阶段 ${stageName} 校验警告:`)
|
|
@@ -2842,6 +3038,11 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
|
|
|
2842
3038
|
}
|
|
2843
3039
|
}
|
|
2844
3040
|
|
|
3041
|
+
// verify 产物校验通过 + 结论非 FAIL(否则上面已阻断),此时才能声称"验证通过"
|
|
3042
|
+
if (stageName === 'verify') {
|
|
3043
|
+
console.log('\n✅ 验证通过,下一步:sillyspec run archive')
|
|
3044
|
+
}
|
|
3045
|
+
|
|
2845
3046
|
// ── Plan postcheck contract:plan.md 必须满足 execute 契约 ──
|
|
2846
3047
|
if (stageName === 'plan') {
|
|
2847
3048
|
const planFile = resolveChangeDir(cwd, progress, platformOpts?.specRoot)
|
|
@@ -2941,23 +3142,21 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
|
|
|
2941
3142
|
console.log('🔗 Worktree: n/a (no meta)');
|
|
2942
3143
|
} else if (meta.mode === 'native-worktree') {
|
|
2943
3144
|
console.log('🔗 Worktree: kept (外部隔离环境)');
|
|
2944
|
-
} else if (meta.mode === 'in-place-fallback') {
|
|
2945
|
-
console.log('🔗 Worktree: n/a (in-place 模式)');
|
|
2946
3145
|
} else {
|
|
3146
|
+
// in-place 模式不再短路:cleanup 现在能安全处理 in-place(只清 meta,不碰主工作区)
|
|
2947
3147
|
const check = wm.hasUnappliedChanges(changeName);
|
|
2948
3148
|
if (check.hasChanges) {
|
|
2949
3149
|
console.log(`🔗 Worktree: pending apply (${check.changedFiles.length} 个未应用变更)`);
|
|
2950
3150
|
console.log(` 下一步: sillyspec worktree apply ${changeName}`);
|
|
2951
3151
|
} else {
|
|
2952
3152
|
const cleanResult = wm.cleanup(changeName);
|
|
2953
|
-
|
|
2954
|
-
|
|
2955
|
-
|
|
2956
|
-
console.
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
2960
|
-
}
|
|
3153
|
+
console.log(`🔗 Worktree: ${cleanResult.result}`);
|
|
3154
|
+
if (cleanResult.residual?.length > 0) {
|
|
3155
|
+
console.warn(` ⚠️ 清理残留: ${cleanResult.residual.join('; ')}`);
|
|
3156
|
+
console.warn(` 手动处理: sillyspec worktree cleanup ${changeName} --force`);
|
|
3157
|
+
} else if (cleanResult.details?.length > 0) {
|
|
3158
|
+
for (const d of cleanResult.details) {
|
|
3159
|
+
if (d.startsWith('⚠️')) console.log(` ${d}`);
|
|
2961
3160
|
}
|
|
2962
3161
|
}
|
|
2963
3162
|
}
|
|
@@ -3213,6 +3412,28 @@ function showStatus(progress, stageName) {
|
|
|
3213
3412
|
}
|
|
3214
3413
|
|
|
3215
3414
|
async function resetStage(pm, progress, stageName, cwd, changeName, platformOpts = {}) {
|
|
3415
|
+
// execute 阶段 reset 时清理自建 worktree,否则下次 run execute 会因 existingMeta 存在
|
|
3416
|
+
// 直接复用带脏状态的旧 worktree(启动逻辑:meta 存在即复用,不查健康状态)
|
|
3417
|
+
if (stageName === 'execute' && changeName) {
|
|
3418
|
+
try {
|
|
3419
|
+
const { WorktreeManager } = await import('./worktree.js')
|
|
3420
|
+
const wm = new WorktreeManager({ cwd })
|
|
3421
|
+
const meta = wm.getMeta(changeName)
|
|
3422
|
+
if (meta) {
|
|
3423
|
+
const cleanResult = wm.cleanup(changeName)
|
|
3424
|
+
if (cleanResult.residual?.length > 0) {
|
|
3425
|
+
console.warn(`⚠️ reset 清理 worktree 残留: ${cleanResult.residual.join('; ')}`)
|
|
3426
|
+
console.warn(` 手动处理: sillyspec worktree cleanup ${changeName} --force`)
|
|
3427
|
+
} else if (cleanResult.result === 'kept') {
|
|
3428
|
+
console.log(`🔗 旧 worktree 保留 (${cleanResult.mode}: 外部隔离环境)`)
|
|
3429
|
+
} else if (cleanResult.result !== 'skipped') {
|
|
3430
|
+
console.log(`🧹 已清理旧 worktree (${cleanResult.result}, mode: ${cleanResult.mode}),下次 execute 将重建干净环境`)
|
|
3431
|
+
}
|
|
3432
|
+
}
|
|
3433
|
+
} catch (e) {
|
|
3434
|
+
console.warn(`⚠️ reset 清理 worktree 失败(不阻断 reset): ${e.message}`)
|
|
3435
|
+
}
|
|
3436
|
+
}
|
|
3216
3437
|
const defSteps = await getStageSteps(stageName, cwd, progress, platformOpts?.specRoot || null)
|
|
3217
3438
|
progress.stages[stageName] = {
|
|
3218
3439
|
status: 'in-progress',
|