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.
Files changed (37) hide show
  1. package/.claude/CLAUDE.md +23 -0
  2. package/.claude/skills/sillyspec-archive/SKILL.md +86 -21
  3. package/.claude/skills/sillyspec-auto/SKILL.md +98 -83
  4. package/.claude/skills/sillyspec-brainstorm/SKILL.md +78 -44
  5. package/.claude/skills/sillyspec-doctor/SKILL.md +61 -31
  6. package/.claude/skills/sillyspec-execute/SKILL.md +90 -30
  7. package/.claude/skills/sillyspec-explore/SKILL.md +96 -109
  8. package/.claude/skills/sillyspec-export/SKILL.md +4 -0
  9. package/.claude/skills/sillyspec-init/SKILL.md +7 -0
  10. package/.claude/skills/sillyspec-plan/SKILL.md +74 -21
  11. package/.claude/skills/sillyspec-propose/SKILL.md +61 -21
  12. package/.claude/skills/sillyspec-quick/SKILL.md +84 -21
  13. package/.claude/skills/sillyspec-scan/SKILL.md +74 -21
  14. package/.claude/skills/sillyspec-state/SKILL.md +11 -1
  15. package/.claude/skills/sillyspec-status/SKILL.md +55 -21
  16. package/.claude/skills/sillyspec-verify/SKILL.md +82 -21
  17. package/.claude/skills/sillyspec-workspace/SKILL.md +12 -0
  18. package/CLAUDE.md +4 -0
  19. package/docs/sillyspec/file-lifecycle/platform-workflows-sync.md +5 -0
  20. package/docs/sillyspec/file-lifecycle/stage-artifacts.md +3 -3
  21. package/docs/sillyspec/file-lifecycle.md +17 -5
  22. package/docs/worktree-isolation.md +1 -1
  23. package/package.json +2 -2
  24. package/src/doctor-diagnostics.js +575 -0
  25. package/src/hooks/worktree-guard.js +111 -111
  26. package/src/index.js +158 -86
  27. package/src/progress.js +68 -0
  28. package/src/quick-recommend.js +115 -0
  29. package/src/run.js +272 -51
  30. package/src/stage-contract.js +23 -5
  31. package/src/stages/quick.js +36 -26
  32. package/src/sync.js +14 -3
  33. package/src/worktree.js +69 -28
  34. package/test/decision-ref-version.mjs +85 -0
  35. package/test/platform-scan-p0.test.mjs +18 -7
  36. package/test/quick-recommend.test.mjs +146 -0
  37. 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
- // 平台模式:清理旧版本残留的 cwd/.sillyspec/(防止源码污染)。
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
- if (existsSync(legacyDir)) {
1284
- let hasChanges = false;
1285
- try {
1286
- const cd = join(legacyDir, 'changes');
1287
- if (existsSync(cd)) hasChanges = readdirSync(cd).length > 0;
1288
- } catch {}
1289
- let hasProjects = false;
1290
- try {
1291
- const pd = join(legacyDir, 'projects');
1292
- if (existsSync(pd)) hasProjects = readdirSync(pd).length > 0;
1293
- } catch {}
1294
- const hasDb = existsSync(join(legacyDir, 'sillyspec.db'));
1295
-
1296
- if (hasChanges || hasProjects || hasDb) {
1297
- console.error('❌ [sillyspec] 拒绝删除源码目录的 .sillyspec/:检测到真实资产。仅清理运行时残留,保留 worktrees 与进度状态。');
1298
- const { cleanupRuntimeResidue } = await import('./init.js')
1299
- cleanupRuntimeResidue(legacyDir);
1300
- } else {
1301
- try { rmSync(legacyDir, { recursive: true, force: true }) } catch {}
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
- // 解析 --change <name>
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(` 继续执行前请解决上述问题,或使用 --no-worktree 跳过。`)
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
- const projectName = progress.project || basename(cwd)
2351
- const contractResult = runValidators('archive', cwd, changeName, { projectName, specRoot: platformOpts?.specRoot })
2352
- if (contractResult.errors.length > 0) {
2353
- console.error(`\n❌ 归档校验失败:`)
2354
- for (const err of contractResult.errors) {
2355
- console.error(` - ${err}`)
2356
- }
2357
- }
2358
- if (contractResult.warnings.length > 0) {
2359
- console.warn(`\n⚠️ 归档校验警告:`)
2360
- for (const w of contractResult.warnings) {
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
- if (cleanResult.result === 'skipped' || cleanResult.result === 'kept') {
2954
- console.log(`🔗 Worktree: ${cleanResult.result}`);
2955
- } else {
2956
- console.log(`🔗 Worktree: ${cleanResult.result}`);
2957
- if (cleanResult.details?.length > 0) {
2958
- for (const d of cleanResult.details) {
2959
- if (d.startsWith('⚠️')) console.log(` ${d}`);
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',