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
@@ -144,8 +144,14 @@ function readIfExists(file) {
144
144
  }
145
145
 
146
146
  function warnMissingIds(warnings, ids, targetContent, targetName, sourceName) {
147
+ // 剥版本后缀(@vN)按基号词边界匹配:target 里写裸号 D-001 即视为引用了 D-001@V1
148
+ // 的当前版本(prompt 常裸号引用,旧版字面 includes("D-001@V1") 会批量误报)。
149
+ // 大小写不敏感:target 整体大写后比对。
150
+ const targetUpper = targetContent.toUpperCase()
147
151
  for (const id of ids) {
148
- if (!targetContent.toUpperCase().includes(id)) {
152
+ const base = id.replace(/@V\d+$/, '')
153
+ const re = new RegExp(`\\b${base}\\b`)
154
+ if (!re.test(targetUpper)) {
149
155
  warnings.push(`${targetName} 未引用 ${sourceName} 中的 ${id}`)
150
156
  }
151
157
  }
@@ -281,9 +287,9 @@ function validateBrainstormOutputs(cwd, changeName, context = {}) {
281
287
  const design = readIfExists(join(changeDir, 'design.md'))
282
288
  const requirements = readIfExists(join(changeDir, 'requirements.md'))
283
289
  const tasks = readIfExists(join(changeDir, 'tasks.md'))
290
+ // decision 的天然引用落点是 design.md;requirements(需求按 FR 组织)与
291
+ // tasks(骨架,待 plan 展开)不强求逐条引用每个架构决策,否则批量误报。
284
292
  warnMissingIds(warnings, decisionIds, design, 'design.md', 'decisions.md')
285
- warnMissingIds(warnings, decisionIds, requirements, 'requirements.md', 'decisions.md')
286
- warnMissingIds(warnings, decisionIds, tasks, 'tasks.md', 'decisions.md')
287
293
  }
288
294
  }
289
295
 
@@ -385,10 +391,11 @@ function validateVerifyOutputs(cwd, changeName, context = {}) {
385
391
  return { ok: false, errors, warnings }
386
392
  }
387
393
 
388
- // verify 阶段应该产出 verify-result.md(或类似报告)
394
+ // verify 阶段必须产出 verify-result.md —— 不存在则不能完成。
395
+ // 历史教训:AI 可能跳过报告直接 --done,导致"假完成"。此处提级为 error 强制阻断。
389
396
  const verifyResult = join(changeDir, 'verify-result.md')
390
397
  if (!existsSync(verifyResult)) {
391
- warnings.push('verify-result.md 不存在(verify 阶段建议产出验证报告)')
398
+ errors.push(`verify-result.md 不存在 — verify 阶段必须产出验证报告才能完成(${verifyResult})`)
392
399
  }
393
400
 
394
401
  // 确保核心规范文件仍然存在
@@ -409,6 +416,17 @@ function validateVerifyOutputs(cwd, changeName, context = {}) {
409
416
  const decisionIds = extractCurrentDecisionIds(decisions)
410
417
  warnMissingIds(warnings, decisionIds, verify, 'verify-result.md', 'decisions.md')
411
418
 
419
+ // ── FAIL 结论门控(适用于所有变更,不限风险等级)──
420
+ // verify-result.md 结论为 FAIL 时,verify 阶段不能 completed。
421
+ // 历史教训:CLI 曾不校验结论,AI 写 FAIL 后 verify 仍被标记完成并提示"验证通过可以归档"。
422
+ const conclusionLine = verify.match(/^##\s*结论\s*\n\s*(PASS(?:\s+WITH\s+NOTES)?|FAIL)/im)
423
+ const conclusionStr = conclusionLine ? conclusionLine[1].toUpperCase().replace(/\s+/g, ' ') : ''
424
+ if (conclusionStr === 'FAIL') {
425
+ errors.push('verify-result.md 结论为 FAIL — 验证未通过,不能标记 verify 完成;请修复后重新运行验证')
426
+ } else if (!conclusionStr) {
427
+ warnings.push('verify-result.md 未识别到「## 结论」章节(应为 PASS / PASS WITH NOTES / FAIL)')
428
+ }
429
+
412
430
  // ── P0: Change Risk Gate — 核心功能缺少真实集成验证时 FAIL ──
413
431
  const changeRiskProfile = detectChangeRisk({
414
432
  designContent: readIfExists(join(changeDir, 'design.md')),
@@ -9,12 +9,12 @@ export const definition = {
9
9
  prompt: `解析任务参数,加载项目上下文。
10
10
 
11
11
  ### 操作
12
- 1. 检查是否携带 \`--change <变更名>\`,确定记录方式
12
+ 1. 检查关联变更(\`<linked-changes>\`,逗号分隔的变更名列表;显示「(无)」= 不关联变更),确定记录方式
13
13
  2. 理解任务:模糊则问一个问题确认
14
14
  3. 加载项目信息:\`cat .sillyspec/projects/*.yaml 2>/dev/null\`(了解项目结构和技术栈)
15
15
  4. 加载上下文:\`cat .sillyspec/docs/<project>/scan/CONVENTIONS.md 2>/dev/null\`
16
16
  5. 加载本地配置:\`cat .sillyspec/local.yaml 2>/dev/null\`(构建命令、测试命令、环境变量等)
17
- 6. 如有 \`--change\`,加载设计文档:\`cat .sillyspec/changes/<change-name>/design.md 2>/dev/null\`(理解设计意图)
17
+ 6. 若有关联变更,加载每个变更的设计文档:\`cat .sillyspec/changes/<c>/design.md 2>/dev/null\`(理解设计意图)
18
18
  7. 如有需要,查询知识库:\`cat .sillyspec/knowledge/INDEX.md 2>/dev/null\`
19
19
 
20
20
  ### 模块文档加载
@@ -24,11 +24,14 @@ export const definition = {
24
24
 
25
25
  ### 创建任务记录(⛔ 此步骤不能跳过,没有 quicklog 记录 = 未完成)
26
26
  理解完任务后,**必须**立即创建记录文件,再输出任何其他内容:
27
+
28
+ **A. 始终创建 QUICKLOG(无论是否关联变更)**
27
29
  1. 使用预注入的 git 用户名:\`<git-user>\`
28
- 2. \`--change\`:创建 .sillyspec/quicklog/QUICKLOG-\`<git-user>\`.md\`(已存在则追加),写入:
30
+ 2. 创建/追加 \`.sillyspec/quicklog/QUICKLOG-\`<git-user>\`.md\`,写入:
29
31
  \`\`\`
30
32
  ## ql-<YYYYMMDD>-<NNN>-<XXXX> | <now-datetime> | <一句话任务描述>
31
33
  状态:进行中
34
+ 关联变更:<linked-changes>
32
35
  文件:<预估要改的文件>
33
36
  \`\`\`
34
37
  - ID 格式:\`ql-YYYYMMDD-NNN-XXXX\`
@@ -36,9 +39,16 @@ export const definition = {
36
39
  - 追加前扫描文件中已有的 \`ql-<当天日期>-\` 前缀的最大序号,+1 作为新序号
37
40
  - 每天从 001 开始,跨日重新计数
38
41
  - 此 ID 可被 design.md / plan.md / archive / module 变更索引引用
39
- 3. 有 \`--change\`:在 \`.sillyspec/changes/<change-name>/tasks.md\` 追加未勾选的 task
40
42
 
41
- 这样 Gate 检测到 .sillyspec/\` 下有变更,就不会拦截后续的代码修改。
43
+ **B. 若关联变更(\`<linked-changes>\` 不为「(无)」)**
44
+ 3. 解析 \`<linked-changes>\`(逗号分隔的变更名列表,可能有多个 = 一次 quick 同时归属多个变更)
45
+ 4. 对**每个**变更 \`<c>\`:在 \`.sillyspec/changes/<c>/tasks.md\` 追加一条未勾选 task:
46
+ \`- [ ] <ql-ID> <一句话任务描述>\`(复用步骤 A 生成的同一个 ql-ID)
47
+ - 若 \`changes/<c>/\` 目录或 tasks.md 不存在,先 \`mkdir -p .sillyspec/changes/<c>\` 再创建 tasks.md
48
+ - 同一个 ql-ID 出现在 QUICKLOG 和所有关联变更的 tasks.md 中,便于交叉引用
49
+ - \`<linked-changes>\` 为「(无)」时跳过本步,仅保留 QUICKLOG
50
+
51
+ 这样 Gate 检测到 \`.sillyspec/\` 下有变更,就不会拦截后续的代码修改。
42
52
 
43
53
  ### 输出
44
54
  quicklog 已创建(必须放在输出的第一行确认)+ 任务理解 + 上下文摘要
@@ -72,27 +82,27 @@ quicklog 已创建(必须放在输出的第一行确认)+ 任务理解 + 上
72
82
  prompt: `Git 暂存并更新任务记录。
73
83
 
74
84
  ### 操作
75
- 1. 查看 \`git status --porcelain\`,确认只包含本次 quick 相关文件
76
- 2. 使用 \`git add -- <file...>\` 暂存本次 quick 实际修改的文件(不要 commit,由用户通过统一提交工具处理)
77
- - 禁止使用 \`git add -A\`
78
- - 不要暂存 quick 开始前就已存在的无关改动
79
- 3. 更新 Step 1 创建的记录:
80
- - \`--change\`:找到对应 ql-ID 的条目,将「状态:进行中」改为「状态:已完成」,补充实际改动文件和结果摘要
81
- - \`--change\`:勾选 tasks.md 中对应的 task checkbox
82
- 4. QUICKLOG 轮转:超过 500 行则重命名为 \`QUICKLOG-<USER>-YYYY-MM-DD.md\`(日期取最后一条记录的日期)。新文件从空开始,ql-ID 需扫描同目录所有 QUICKLOG 文件中当天最大序号 +1
83
- 5. 如果发现项目特有的坑,追加到 \`.sillyspec/knowledge/uncategorized.md\`
84
- 6. 任务比预期复杂 → 建议用完整流程
85
-
86
- ### 模块文档同步
87
- 7. 读取 \`.sillyspec/docs/<project>/modules/_module-map.yaml\`(不存在则跳过以下步骤)
88
- 8. 对比本次修改的文件(\`git diff --name-only HEAD\`)与模块映射
89
- 9. 如果命中模块 → 直接同步模块文档:
90
- - 读取对应的 \`.sillyspec/docs/<project>/modules/<module>.md\`(如不存在则新建)
91
- - 根据本次改动内容更新模块文档(正文描述当前状态,底部变更索引追加本次 ql-ID)
92
- - 变更索引格式:\`- ql-YYYYMMDD-NNN-XXXX | <一句话描述>\`
93
- - 写入模块文档
94
- - 使用 \`git add -- <module-doc>\` 暂存更新的模块文件
95
- 10. 未命中任何模块 → 跳过,不做额外操作
85
+ 1. 查看 \`git status --porcelain\`,确认只包含本次 quick 相关文件
86
+ 2. 使用 \`git add -- <file...>\` 暂存本次 quick 实际修改的文件(不要 commit,由用户通过统一提交工具处理)
87
+ - 禁止使用 \`git add -A\`
88
+ - 不要暂存 quick 开始前就已存在的无关改动
89
+ 3. 更新 Step 1 创建的记录:
90
+ - QUICKLOG:找到对应 ql-ID 的条目,将「状态:进行中」改为「状态:已完成」,补充实际改动文件和结果摘要(**始终做**,无论是否关联变更)
91
+ - 关联变更:对 Step 1 在每个 \`changes/<c>/tasks.md\` 写入的 ql-ID task,将其 checkbox 由 \`- [ ]\` 勾选为 \`- [x]\`;若 Step 1 未写入(用户事后才决定关联),则按 Step 1 的格式补写并直接勾选
92
+ 4. QUICKLOG 轮转:超过 500 行则重命名为 \`QUICKLOG-<USER>-YYYY-MM-DD.md\`(日期取最后一条记录的日期)。新文件从空开始,ql-ID 需扫描同目录所有 QUICKLOG 文件中当天最大序号 +1
93
+ 5. 如果发现项目特有的坑,追加到 \`.sillyspec/knowledge/uncategorized.md\`
94
+ 6. 任务比预期复杂 → 建议用完整流程
95
+
96
+ ### 模块文档同步
97
+ 7. 读取 \`.sillyspec/docs/<project>/modules/_module-map.yaml\`(不存在则跳过以下步骤)
98
+ 8. 对比本次修改的文件(\`git diff --name-only HEAD\`)与模块映射
99
+ 9. 如果命中模块 → 直接同步模块文档:
100
+ - 读取对应的 \`.sillyspec/docs/<project>/modules/<module>.md\`(如不存在则新建)
101
+ - 根据本次改动内容更新模块文档(正文描述当前状态,底部变更索引追加本次 ql-ID)
102
+ - 变更索引格式:\`- ql-YYYYMMDD-NNN-XXXX | <一句话描述>\`
103
+ - 写入模块文档
104
+ - 使用 \`git add -- <module-doc>\` 暂存更新的模块文件
105
+ 10. 未命中任何模块 → 跳过,不做额外操作
96
106
 
97
107
  ### 输出
98
108
  暂存确认 + 记录路径 + 模块文档同步结果(如有)`,
package/src/sync.js CHANGED
@@ -10,6 +10,17 @@
10
10
 
11
11
  import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from 'fs';
12
12
  import { join } from 'path';
13
+ import { resolvePlatformSpecDir } from './progress.js';
14
+
15
+ // sync 是 best-effort(网络失败只 warn):平台指针失效时不抛,跳过平台、回退本地。
16
+ function safePlatformSpecDir(cwd) {
17
+ try {
18
+ return resolvePlatformSpecDir(cwd);
19
+ } catch (e) {
20
+ console.warn(`[sync] 平台指针不可用,跳过平台同步:${String(e.message).split('\n')[0]}`);
21
+ return undefined;
22
+ }
23
+ }
13
24
 
14
25
  const LOCAL_YAML = '.sillyspec/local.yaml';
15
26
  const CHANGES_DIR = '.sillyspec/changes';
@@ -200,7 +211,7 @@ export class SyncManager {
200
211
  let progressData;
201
212
  try {
202
213
  const { ProgressManager } = await import('./progress.js');
203
- const pm = new ProgressManager();
214
+ const pm = new ProgressManager({ specDir: safePlatformSpecDir(this.cwd) });
204
215
  progressData = await pm.read(this.cwd, changeName);
205
216
  } catch (err) {
206
217
  console.warn(`[sync] 读取 progress 失败 (${changeName}): ${err.message}`);
@@ -225,7 +236,7 @@ export class SyncManager {
225
236
  // 更新 platform_last_sync
226
237
  try {
227
238
  const { ProgressManager } = await import('./progress.js');
228
- const pm = new ProgressManager();
239
+ const pm = new ProgressManager({ specDir: safePlatformSpecDir(this.cwd) });
229
240
  await pm._updatePlatformLastSync(this.cwd, changeName);
230
241
  } catch (err) {
231
242
  console.warn(`[sync] 更新 platform_last_sync 失败: ${err.message}`);
@@ -326,7 +337,7 @@ export class SyncManager {
326
337
  // 更新本地 approvals 表
327
338
  try {
328
339
  const { ProgressManager } = await import('./progress.js');
329
- const pm = new ProgressManager();
340
+ const pm = new ProgressManager({ specDir: safePlatformSpecDir(this.cwd) });
330
341
  await pm._updateApprovalStatus(this.cwd, changeName, result.status, result.reason);
331
342
  } catch (err) {
332
343
  console.warn(`[sync] 更新本地审批状态失败: ${err.message}`);
package/src/worktree.js CHANGED
@@ -73,6 +73,17 @@ function parseJSON(raw) {
73
73
  try { return JSON.parse(raw); } catch { return null; }
74
74
  }
75
75
 
76
+ /**
77
+ * 跨平台同步等待(cleanup 重试退避用)
78
+ * 不依赖外部 sleep 命令——Windows cmd.exe 无 sleep;当 Git for Windows 未把 usr/bin 加入
79
+ * PATH 时,execSync('sleep 0.5') 会抛错并从 catch 块冒泡,直接中断整个 cleanup()。
80
+ * 改用 busy-wait 规避,保证 retry 之间真有间隔且不依赖环境。
81
+ */
82
+ function sleepMs(ms) {
83
+ const end = Date.now() + ms;
84
+ while (Date.now() < end) { /* spin */ }
85
+ }
86
+
76
87
  function computeBaselineHash(cwd) {
77
88
  // 排除 .sillyspec/ 元数据目录,避免 brainstorm/plan 阶段修改的蓝图文件污染 baseline
78
89
  const exclude = '-- . ":(exclude).sillyspec/"';
@@ -198,7 +209,7 @@ export class WorktreeManager {
198
209
  if (isolation.inSubmodule) {
199
210
  throw new Error(
200
211
  '当前目录在 git submodule 内,SillySpec worktree 不支持在 submodule 中创建。' +
201
- '\n请在主仓库中执行,或使用 --no-worktree 跳过隔离。'
212
+ '\n请在主仓库根目录执行 execute。'
202
213
  );
203
214
  }
204
215
  if (isolation.inWorktree) {
@@ -237,6 +248,10 @@ export class WorktreeManager {
237
248
  if (!this.getMeta(name)) {
238
249
  console.log(`⚠️ 检测到幽灵 worktree 目录(无 meta.json),自动清理...`);
239
250
  try { rmSync(worktreePath, { recursive: true, force: true }); } catch {}
251
+ // 同步清理 git worktree 注册 + 残留分支,否则目录虽删但 git 内部状态未清,
252
+ // 后续 git worktree add 会因「worktree 已注册」或「分支已存在」失败
253
+ try { gitQuiet(this.cwd, 'worktree prune'); } catch {}
254
+ try { gitQuiet(this.cwd, `branch -D ${branch}`); } catch {}
240
255
  } else {
241
256
  throw new Error(`worktree already exists: ${name}. Run cleanup first.`);
242
257
  }
@@ -269,7 +284,7 @@ export class WorktreeManager {
269
284
  } catch (e) {
270
285
  const check = isGitWorktreeSupported(this.cwd);
271
286
  if (!check.supported) {
272
- throw new Error(`git worktree add 失败: ${e.stderr || e.message}\n\n${check.reason ? `原因: ${check.reason}` : ''}\n建议: 使用 --no-worktree 标志跳过隔离,或升级 git 到 >= 2.15`);
287
+ throw new Error(`git worktree add 失败: ${e.stderr || e.message}\n\n${check.reason ? `原因: ${check.reason}` : ''}\n建议: 升级 git 到 >= 2.15;或运行 \`sillyspec worktree doctor --fix\` 检查 worktree 状态。`);
273
288
  }
274
289
  // sandbox/permission fallback: 降级为 in-place + baseline protection
275
290
  console.log(`⚠️ git worktree add 失败(可能是沙箱权限限制),降级为 in-place 模式 + baseline protection`);
@@ -546,7 +561,10 @@ export class WorktreeManager {
546
561
  * 三重清理:git worktree 注册 + worktree 目录 + meta 目录。
547
562
  * @param {string} changeName
548
563
  * @param {{ force?: boolean, maxRetries?: number }} opts
549
- * @returns {{ result: 'cleaned'|'force-cleaned'|'skipped'|'kept', mode: string|null, details: string[] }}
564
+ * @returns {{ result: 'cleaned'|'force-cleaned'|'skipped'|'kept'|'partial', mode: string|null, details: string[], residual: string[] }}
565
+ * result 取值:cleaned=git remove 成功;force-cleaned=git remove 失败但 fallback 清理完成;
566
+ * partial=有残留(目录/meta/git 注册未清);skipped=无需清理;kept=native-worktree 保留。
567
+ * residual:未清干净的路径/引用列表(空数组表示干净)。
550
568
  */
551
569
  cleanup(changeName, { force = false, maxRetries = 3 } = {}) {
552
570
  const name = validateChangeName(changeName);
@@ -561,22 +579,20 @@ export class WorktreeManager {
561
579
  }
562
580
 
563
581
  const mode = meta?.mode || 'worktree';
582
+ // in-place 模式:worktreePath === 主工作区,绝对禁止删除目录本身,但 meta 目录仍应清理
583
+ // (否则永久残留)。native-worktree:外部隔离环境,整体跳过。
584
+ const isInPlace = mode === 'in-place-fallback';
564
585
 
565
- // 安全检查:只有 SillySpec 创建的 worktree 才允许删除
566
- if (!force) {
567
- if (mode === 'native-worktree') {
568
- return { result: 'kept', mode, details: ['native-worktree: 外部隔离环境,跳过清理'] };
569
- }
570
- if (mode === 'in-place-fallback') {
571
- return { result: 'skipped', mode, details: ['in-place-fallback: 无隔离目录,跳过清理'] };
572
- }
586
+ // 安全检查:native-worktree 是外部隔离环境,非 force 不碰
587
+ if (!force && mode === 'native-worktree') {
588
+ return { result: 'kept', mode, details: ['native-worktree: 外部隔离环境,跳过清理'], residual: [] };
573
589
  }
574
590
 
575
591
  const branch = (meta && meta.branch) || BRANCH_PREFIX + name;
576
592
 
577
- // 1. git worktree remove(带 retry
593
+ // 1. git worktree remove(带 retry)—— in-place 跳过:无 git worktree 注册,且 worktreePath 即主工作区
578
594
  let gitRemoveOk = false;
579
- if (existsSync(worktreePath)) {
595
+ if (!isInPlace && existsSync(worktreePath)) {
580
596
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
581
597
  try {
582
598
  git(this.cwd, `worktree remove ${worktreePath} --force`);
@@ -586,15 +602,15 @@ export class WorktreeManager {
586
602
  } catch (e) {
587
603
  details.push(`git worktree remove attempt ${attempt}/${maxRetries} failed: ${e.message}`);
588
604
  if (attempt < maxRetries) {
589
- // 短暂等待后重试
590
- execSync('sleep 0.5', { stdio: 'pipe' });
605
+ // 短暂等待后重试(跨平台 busy-wait,见 sleepMs)
606
+ sleepMs(500);
591
607
  }
592
608
  }
593
609
  }
594
610
  }
595
611
 
596
- // 2. fallback: 确保 worktree 目录已删除
597
- if (existsSync(worktreePath)) {
612
+ // 2. fallback: 确保 worktree 目录已删除(in-place 跳过——worktreePath 是主工作区)
613
+ if (!isInPlace && existsSync(worktreePath)) {
598
614
  try {
599
615
  rmSync(worktreePath, { recursive: true, force: true });
600
616
  details.push('worktree directory force-removed (fallback)');
@@ -618,7 +634,7 @@ export class WorktreeManager {
618
634
  // 分支可能已被删除,幂等跳过
619
635
  }
620
636
 
621
- // 5. 清除 meta 目录
637
+ // 5. 清除 meta 目录(in-place 模式也执行——这是 in-place meta 残留的修复点)
622
638
  if (existsSync(metaDir)) {
623
639
  try {
624
640
  rmSync(metaDir, { recursive: true, force: true });
@@ -628,18 +644,27 @@ export class WorktreeManager {
628
644
  }
629
645
  }
630
646
 
631
- // 6. 最终验证:确认三重清理完成
647
+ // 6. 最终验证:确认清理完成(in-place 模式 worktreePath=主工作区,不纳入残留检查)
632
648
  const residual = [];
633
- if (existsSync(worktreePath)) residual.push(`worktree dir: ${worktreePath}`);
649
+ if (!isInPlace && existsSync(worktreePath)) residual.push(`worktree dir: ${worktreePath}`);
634
650
  if (existsSync(metaDir)) residual.push(`meta dir: ${metaDir}`);
635
- if (gitQuiet(this.cwd, `worktree list`)?.includes(worktreePath)) {
651
+ if (!isInPlace && gitQuiet(this.cwd, `worktree list`)?.includes(worktreePath)) {
636
652
  residual.push('git worktree list still references this worktree');
637
653
  }
638
654
  if (residual.length > 0) {
639
655
  details.push(`⚠️ 残留: ${residual.join('; ')}`);
640
656
  }
641
657
 
642
- return { result: gitRemoveOk ? 'cleaned' : 'force-cleaned', mode, details };
658
+ // result:有残留→partial;in-place(无隔离目录可删,只清了 meta)→cleaned;否则按 git remove 成败
659
+ let result;
660
+ if (residual.length > 0) {
661
+ result = 'partial';
662
+ } else if (isInPlace) {
663
+ result = 'cleaned';
664
+ } else {
665
+ result = gitRemoveOk ? 'cleaned' : 'force-cleaned';
666
+ }
667
+ return { result, mode, details, residual };
643
668
  }
644
669
 
645
670
  /**
@@ -680,8 +705,10 @@ export class WorktreeManager {
680
705
  for (const entry of entries) {
681
706
  const lines = entry.split('\n');
682
707
  const wtPath = lines.find(l => l.startsWith('worktree '))?.replace('worktree ', '');
708
+ // git 2.20+ porcelain 在目录缺失时输出 `missing` 行;作为 existsSync 的权威交叉验证
709
+ const missing = lines.some(l => l === 'missing');
683
710
  if (wtPath && wtPath !== this.cwd) { // 排除主工作区
684
- gitWorktreeList.push({ path: wtPath, raw: entry });
711
+ gitWorktreeList.push({ path: wtPath, missing, raw: entry });
685
712
  }
686
713
  }
687
714
  } catch {
@@ -693,12 +720,25 @@ export class WorktreeManager {
693
720
  const metaNames = new Set(metaEntries.map(m => m.changeName));
694
721
 
695
722
  // 3. 检查 git worktree list 中的孤儿条目
723
+ // ⚠️ 只在 git 自己标记 missing 时自动 prune。目录不可见但 git 未标记 missing 的
724
+ // 情况(平台模式/容器路径不一致、符号链接、Windows 路径格式、旧 git 无 missing 标记)
725
+ // 只告警不自动 prune —— existsSync 在这些场景会误判,自动 prune 会杀死实际在用的
726
+ // worktree(sillyspec-worktree-platform-mode-bug)。git 内部状态是 worktree 生命周期的
727
+ // 权威来源,用它做 prune 前提可杜绝误杀。
696
728
  for (const wt of gitWorktreeList) {
697
729
  if (!existsSync(wt.path)) {
698
730
  const name = this._pathToChangeName(wt.path);
699
- issues.push({ type: 'orphan-git-entry', name: name || wt.path, detail: `git worktree 引用存在但目录不存在: ${wt.path}`, fixable: true });
700
- if (fix) {
701
- try { gitQuiet(this.cwd, 'worktree prune'); fixed.push(`pruned orphan: ${wt.path}`); } catch { unfixable.push(`prune failed for: ${wt.path}`); }
731
+ if (wt.missing === true) {
732
+ // git 明确标记目录缺失(git 2.20+)→ 真 orphan,prune 安全
733
+ issues.push({ type: 'orphan-git-entry', name: name || wt.path, detail: `git worktree 标记 missing: ${wt.path}`, fixable: true });
734
+ if (fix) {
735
+ try { gitQuiet(this.cwd, 'worktree prune'); fixed.push(`pruned orphan: ${wt.path}`); } catch { unfixable.push(`prune failed for: ${wt.path}`); }
736
+ }
737
+ } else {
738
+ // 目录不可见但 git 未标记 missing → 可能 existsSync 误判,保守不自动 prune
739
+ issues.push({ type: 'orphan-git-entry', name: name || wt.path,
740
+ detail: `git worktree 引用 ${wt.path} 目录不可见但 git 未标记 missing——可能路径/权限导致误判,未自动 prune(避免误杀在用的 worktree)。确认废弃后手动: git worktree prune`,
741
+ fixable: false });
702
742
  }
703
743
  }
704
744
  }
@@ -761,9 +801,10 @@ export class WorktreeManager {
761
801
  if (meta && meta.createdAt) {
762
802
  const ageMs = Date.now() - new Date(meta.createdAt).getTime();
763
803
  const ageHours = ageMs / (1000 * 60 * 60);
804
+ const staleFixable = meta.mode !== 'native-worktree';
764
805
  if (ageHours > staleHours) {
765
- issues.push({ type: 'stale', name, detail: `worktree 已存在 ${Math.round(ageHours)} 小时(超过 ${staleHours}h 阈值)`, fixable: true });
766
- if (fix && meta.mode !== 'native-worktree') {
806
+ issues.push({ type: 'stale', name, detail: `worktree 已存在 ${Math.round(ageHours)} 小时(超过 ${staleHours}h 阈值)${staleFixable ? '' : '(native-worktree 外部环境,不可自动清理)'}`, fixable: staleFixable });
807
+ if (fix && staleFixable) {
767
808
  try {
768
809
  const result = this.cleanup(name);
769
810
  if (result.result === 'cleaned' || result.result === 'force-cleaned') {
@@ -0,0 +1,85 @@
1
+ /**
2
+ * 回归:decision 引用校验不应因「版本号有无 / 大小写」误报
3
+ *
4
+ * 现象:prompt 让用 D-xxx@v1,但 design/requirements 里常裸号引用 D-xxx。
5
+ * 旧校验器用 targetContent.includes("D-xxx@V1") 字面匹配 → 批量误报。
6
+ * 另:强制 decision 在 design+requirements+tasks 三处都引用,tasks 骨架不自然。
7
+ *
8
+ * 修 A:warnMissingIds 剥 @vN 后缀,按基号词边界匹配(裸号 D-001 视为引用 D-001@V1)。
9
+ * 修 B:brainstorm 阶段不再强制 requirements.md / tasks.md 引用每个 decision(decision 天然落点在 design)。
10
+ */
11
+ import { runValidators } from '../src/stage-contract.js'
12
+ import { mkdirSync, writeFileSync, rmSync } from 'fs'
13
+ import { join } from 'path'
14
+ import os from 'os'
15
+
16
+ let failed = 0
17
+ const fail = m => { failed++; console.log(` ❌ FAIL: ${m}`) }
18
+ const pass = m => console.log(` ✅ PASS: ${m}`)
19
+
20
+ function setup() {
21
+ const tmp = join(os.tmpdir(), `ss-ref-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`)
22
+ const cd = join(tmp, '.sillyspec', 'changes', 'tc')
23
+ mkdirSync(cd, { recursive: true })
24
+ return { tmp, cd }
25
+ }
26
+
27
+ console.log('=== decision 引用校验:版本号 / 裸号 / 三文件放宽 ===\n')
28
+
29
+ {
30
+ const { tmp, cd } = setup()
31
+ // decisions:小写 @v1(测大小写不敏感)+ 一条 supersede 链
32
+ writeFileSync(join(cd, 'decisions.md'), [
33
+ '# Decisions', '',
34
+ '## D-001@v1: 决策一', 'status: accepted', 'priority: P2', '',
35
+ '## D-002@v1: 决策二', 'status: accepted', 'priority: P2', '',
36
+ '## D-003@v1: 旧', 'status: superseded', 'supersedes: ', '',
37
+ '## D-003@v2: 新', 'status: accepted', 'supersedes: D-003@v1', '',
38
+ ].join('\n'))
39
+ // design:裸号引用 D-001(小写原文);不引用 D-002(真实缺口);D-003@v2 裸号 D-003
40
+ writeFileSync(join(cd, 'design.md'), [
41
+ '# Design', '',
42
+ '## 文件变更清单', '- src/a.js 覆盖 D-001', '- src/b.py 涉及 D-003', '',
43
+ '## 风险登记', '- 风险', '',
44
+ '## 自审', '- 已审', '',
45
+ ].join('\n'))
46
+ writeFileSync(join(cd, 'proposal.md'), '# P\n\n## 不在范围内\n- x\n')
47
+ writeFileSync(join(cd, 'requirements.md'), '# Req\n\n- FR-01: 功能\n')
48
+ // tasks:骨架,不引用任何 decision
49
+ writeFileSync(join(cd, 'tasks.md'), '# Tasks\n\n## W1\n- [ ] (待 plan 展开)\n')
50
+
51
+ const r = runValidators('brainstorm', tmp, 'tc', {})
52
+ const ref = r.warnings.filter(w => w.includes('未引用'))
53
+ console.log(' 产出未引用警告:')
54
+ for (const w of ref) console.log(` - ${w}`)
55
+
56
+ // 修 A:裸号 D-001(小写)应被识别,design 不报 D-001
57
+ if (ref.some(w => w.includes('design.md') && w.includes('D-001'))) {
58
+ fail('design 裸号 D-001(小写)仍被报 —— 修A 未生效(版本号/大小写)')
59
+ } else pass('design 裸号 D-001 被识别(修A:剥版本号 + 大小写不敏感)')
60
+
61
+ // 修 A:D-003 裸号应匹配 active 的 D-003@V2(V1 被 supersede 排除)
62
+ if (ref.some(w => w.includes('design.md') && w.includes('D-003'))) {
63
+ fail('design 裸号 D-003 未匹配到 active D-003@V2')
64
+ } else pass('design 裸号 D-003 匹配 active D-003@V2(supersede 链正确)')
65
+
66
+ // 真实缺口保留:design 确实没引用 D-002
67
+ if (ref.some(w => w.includes('design.md') && w.includes('D-002'))) {
68
+ pass('design 未引用 D-002 正确报警(真实缺口保留)')
69
+ } else fail('design D-002 缺口未报警 —— 校验器被改得过松')
70
+
71
+ // 修 B:requirements / tasks 不再被强制引用每个 decision
72
+ if (ref.some(w => w.includes('requirements.md'))) {
73
+ fail('requirements 仍被强制引用 decision —— 修B 未生效')
74
+ } else pass('requirements 不再强制引用 decision(修B)')
75
+ if (ref.some(w => w.includes('tasks.md'))) {
76
+ fail('tasks 骨架仍被强制引用 decision —— 修B 未生效')
77
+ } else pass('tasks 骨架不再强制引用 decision(修B)')
78
+
79
+ rmSync(tmp, { recursive: true, force: true })
80
+ }
81
+
82
+ console.log('\n' + '='.repeat(50))
83
+ console.log(failed === 0 ? '✅ 全部通过' : `❌ 失败 ${failed}`)
84
+ console.log('='.repeat(50))
85
+ if (failed > 0) process.exit(1)
@@ -140,21 +140,32 @@ function assert(label, condition, detail) {
140
140
 
141
141
  // ── 测试 6:quick step 1 prompt 强制要求 quicklog ──
142
142
  {
143
- const { definition } = await import('../src/stages/quick.js')
144
- const step1Prompt = definition.steps[0].prompt
145
- const step3Prompt = definition.steps[2].prompt
143
+ const { definition } = await import('../src/stages/quick.js')
144
+ const step1Prompt = definition.steps[0].prompt
145
+ const step3Prompt = definition.steps[2].prompt
146
146
 
147
147
  assert('quick step 1 包含 ⛔ 标记', step1Prompt.includes('⛔'))
148
148
  assert('quick step 1 包含「不能跳过」', step1Prompt.includes('不能跳过'))
149
- assert('quick step 1 包含 quicklog 未创建 warning', step1Prompt.includes('quicklog 未创建'))
150
- assert('quick step 1 输出要求 quicklog 第一行', step1Prompt.includes('第一行确认'))
151
- assert('quick step 3 禁止 git add -A', step3Prompt.includes('禁止使用 `git add -A`'))
152
- assert('quick step 3 使用 scoped git add', step3Prompt.includes('git add -- <file...>'))
149
+ assert('quick step 1 包含 quicklog 未创建 warning', step1Prompt.includes('quicklog 未创建'))
150
+ assert('quick step 1 输出要求 quicklog 第一行', step1Prompt.includes('第一行确认'))
151
+ assert('quick step 3 禁止 git add -A', step3Prompt.includes('禁止使用 `git add -A`'))
152
+ assert('quick step 3 使用 scoped git add', step3Prompt.includes('git add -- <file...>'))
153
+
154
+ // 新语义:QUICKLOG 始终记录 + 多变更关联
155
+ assert('quick step 1 含 <linked-changes> 占位符', step1Prompt.includes('<linked-changes>'))
156
+ assert('quick step 1 含「关联变更」措辞', step1Prompt.includes('关联变更'))
157
+ assert('quick step 1 QUICKLOG 始终创建', step1Prompt.includes('始终创建 QUICKLOG'))
158
+ assert('quick step 3 QUICKLOG 始终更新', step3Prompt.includes('始终做'))
153
159
 
154
160
  // run.js 审计包含 quicklog 检查
155
161
  const runSrc = await readFile(join(__dirname, '..', 'src', 'run.js'), 'utf8')
156
162
  assert('quick 审计检查 quicklog 目录存在', runSrc.includes('quicklog 目录不存在'))
157
163
  assert('quick 审计检查 quicklog 为空', runSrc.includes('quicklog 目录为空'))
164
+
165
+ // run.js 多变更交互入口 + 推荐打分
166
+ assert('run.js 含 resolveQuickLinkedChanges 交互入口', runSrc.includes('resolveQuickLinkedChanges'))
167
+ assert('run.js 含 recommendChanges 推荐打分', runSrc.includes('recommendChanges'))
168
+ assert('run.js 含 <linked-changes> 占位符注入', runSrc.includes('<linked-changes>'))
158
169
  }
159
170
 
160
171
  // ── 结果 ──