sillyspec 3.20.6 → 3.21.0

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 (80) 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/package.json +2 -2
  20. package/src/change-list.js +51 -16
  21. package/src/contract-matrix.js +67 -0
  22. package/src/doctor-diagnostics.js +575 -0
  23. package/src/hooks/worktree-guard.js +111 -111
  24. package/src/index.js +154 -82
  25. package/src/progress.js +59 -9
  26. package/src/run.js +139 -38
  27. package/src/stage-contract.js +14 -2
  28. package/src/stages/execute.js +37 -18
  29. package/src/stages/plan-postcheck.js +234 -0
  30. package/src/stages/plan.js +13 -0
  31. package/src/sync.js +13 -3
  32. package/src/worktree.js +69 -28
  33. package/docs/brainstorm-plan-contract.md +0 -64
  34. package/docs/plan-execute-contract.md +0 -123
  35. package/docs/platform-scan-protocol.md +0 -298
  36. package/docs/revision-mode.md +0 -115
  37. package/docs/sillyspec/file-lifecycle/known-implementation-gaps.md +0 -99
  38. package/docs/sillyspec/file-lifecycle/platform-workflows-sync.md +0 -223
  39. package/docs/sillyspec/file-lifecycle/stage-artifacts.md +0 -167
  40. package/docs/sillyspec/file-lifecycle/storage-and-state.md +0 -148
  41. package/docs/sillyspec/file-lifecycle/worktree-and-guard.md +0 -211
  42. package/docs/sillyspec/file-lifecycle.md +0 -132
  43. package/docs/workflow-contract-regression.md +0 -106
  44. package/docs/worktree-isolation.md +0 -252
  45. package/test/brainstorm-plan-contract.test.mjs +0 -273
  46. package/test/check-syntax.mjs +0 -26
  47. package/test/cli-top-level-aliases.test.mjs +0 -174
  48. package/test/contract-artifacts.test.mjs +0 -323
  49. package/test/decision-ref-version.mjs +0 -85
  50. package/test/decision-supersede.test.mjs +0 -277
  51. package/test/knowledge-match.test.mjs +0 -231
  52. package/test/plan-execute-contract.test.mjs +0 -357
  53. package/test/plan-optimization.test.mjs +0 -572
  54. package/test/platform-artifacts.test.mjs +0 -190
  55. package/test/platform-failure-samples.test.mjs +0 -199
  56. package/test/platform-recovery-chain.test.mjs +0 -179
  57. package/test/platform-recovery.test.mjs +0 -167
  58. package/test/platform-scan-p0.test.mjs +0 -186
  59. package/test/quick-recommend.test.mjs +0 -146
  60. package/test/revision-v1.test.mjs +0 -1145
  61. package/test/run-sanitize-project-name.test.mjs +0 -51
  62. package/test/run-scan-postcheck-fail.test.mjs +0 -64
  63. package/test/run-scan-project-parse.test.mjs +0 -200
  64. package/test/run-tests.mjs +0 -48
  65. package/test/runtime-cleanup-keeps-worktree.test.mjs +0 -107
  66. package/test/scan-docs-yaml-placeholders.test.mjs +0 -84
  67. package/test/scan-knowledge.test.mjs +0 -175
  68. package/test/scan-paths.test.mjs +0 -68
  69. package/test/scan-postcheck-project-priority.test.mjs +0 -85
  70. package/test/scan-postcheck.test.mjs +0 -197
  71. package/test/scan-workflow-anyfailed-block.test.mjs +0 -52
  72. package/test/spec-dir.test.mjs +0 -206
  73. package/test/stage-contract-failed-post-check.test.mjs +0 -102
  74. package/test/stage-contract.test.mjs +0 -301
  75. package/test/stage-definitions.test.mjs +0 -39
  76. package/test/wait-gates.test.mjs +0 -501
  77. package/test/workflow-spec-base.test.mjs +0 -142
  78. package/test/worktree-deps-provision.test.mjs +0 -148
  79. package/test/worktree-guard.test.mjs +0 -136
  80. package/test/worktree-native-overlay.test.mjs +0 -188
@@ -11,6 +11,8 @@
11
11
  */
12
12
  import { existsSync, readFileSync, readdirSync } from 'fs'
13
13
  import { join as pJoin } from 'path'
14
+ import jsYaml from 'js-yaml'
15
+ import { parseFileChangeList } from '../change-list.js'
14
16
 
15
17
  // ═══════════════════════════════════════════════════════════════
16
18
  // 解析工具(从 plan.js 迁移)
@@ -90,6 +92,47 @@ function hasTddOrVerify(content) {
90
92
  return /##\s*TDD/.test(content) || /##\s*验证/.test(content) || /##\s*Verify/.test(content)
91
93
  }
92
94
 
95
+ /**
96
+ * 解析 task-NN.md 的跨任务契约字段 provides / expects_from
97
+ * 用 js-yaml 解析 frontmatter(嵌套结构,正则不可靠)。
98
+ * provides/expects_from 为可选字段;缺失或解析失败时返回空(不阻断)。
99
+ * @param {string} content - task 文件内容
100
+ * @returns {{ provides: Array<{contract, fields}>, expectsFrom: Record<string, Array<{contract, needs}>> }}
101
+ */
102
+ export function parseTaskContracts(content) {
103
+ const fmMatch = content.match(/^---\n([\s\S]*?)\n---/)
104
+ if (!fmMatch) return { provides: [], expectsFrom: {} }
105
+ let fm
106
+ try {
107
+ fm = jsYaml.load(fmMatch[1]) || {}
108
+ } catch {
109
+ // frontmatter 非合法 YAML(老格式/手写不规范)—— 当作无契约字段,不阻断
110
+ return { provides: [], expectsFrom: {} }
111
+ }
112
+
113
+ const provides = Array.isArray(fm.provides)
114
+ ? fm.provides.map(p => ({
115
+ contract: String(p?.contract || ''),
116
+ fields: Array.isArray(p?.fields) ? p.fields.map(String) : [],
117
+ })).filter(p => p.contract)
118
+ : []
119
+
120
+ const expectsFrom = {}
121
+ if (fm.expects_from && typeof fm.expects_from === 'object' && !Array.isArray(fm.expects_from)) {
122
+ for (const [providerTask, contracts] of Object.entries(fm.expects_from)) {
123
+ if (!Array.isArray(contracts)) continue
124
+ expectsFrom[providerTask] = contracts
125
+ .map(c => ({
126
+ contract: String(c?.contract || ''),
127
+ needs: Array.isArray(c?.needs) ? c.needs.map(String) : [],
128
+ }))
129
+ .filter(c => c.contract)
130
+ }
131
+ }
132
+
133
+ return { provides, expectsFrom }
134
+ }
135
+
93
136
  // ═══════════════════════════════════════════════════════════════
94
137
  // 核心逻辑
95
138
  // ═══════════════════════════════════════════════════════════════
@@ -222,6 +265,171 @@ export function validateBlueprintConsistency(changeDir) {
222
265
  return { ok: errors.length === 0, errors, warnings }
223
266
  }
224
267
 
268
+ /**
269
+ * 跨任务契约校验器:对账 consumer.expects_from ↔ provider.provides
270
+ *
271
+ * 修复场景:consumer task 声明期望 provider 产出某 DTO 的某字段,
272
+ * 但 provider 的 provides 未承诺(或字段缺失)→ plan 阶段阻断,
273
+ * 避免到 execute/verify 才暴露(典型表现:前端 fallback 错误字段 → 403/500)。
274
+ *
275
+ * provides / expects_from 均为可选字段:未声明时不校验(向后兼容)。
276
+ * @param {string} changeDir - 变更目录
277
+ * @returns {{ ok: boolean, errors: string[], warnings: string[] }}
278
+ */
279
+ export function validateCrossTaskContracts(changeDir) {
280
+ const errors = []
281
+ const warnings = []
282
+
283
+ const tasksDir = pJoin(changeDir, 'tasks')
284
+ if (!existsSync(tasksDir)) {
285
+ return { ok: true, errors, warnings }
286
+ }
287
+
288
+ const taskFiles = readdirSync(tasksDir).filter(f => /^task-\d+\.md$/.test(f))
289
+ if (taskFiles.length === 0) {
290
+ return { ok: true, errors, warnings }
291
+ }
292
+
293
+ // 第一遍:收集每个 task 的 provides(taskId → Map(contract → Set(fields)))
294
+ const providesByTask = new Map()
295
+ for (const file of taskFiles) {
296
+ const content = readFileSync(pJoin(tasksDir, file), 'utf8')
297
+ const taskId = parseTaskId(content, file)
298
+ if (!taskId) continue
299
+ const { provides } = parseTaskContracts(content)
300
+ const contractMap = new Map()
301
+ for (const p of provides) {
302
+ contractMap.set(p.contract, new Set(p.fields))
303
+ }
304
+ providesByTask.set(taskId, contractMap)
305
+ }
306
+
307
+ // 第二遍:校验每个 consumer 的 expects_from 是否被 provider.provides 覆盖
308
+ for (const file of taskFiles) {
309
+ const content = readFileSync(pJoin(tasksDir, file), 'utf8')
310
+ const consumerId = parseTaskId(content, file)
311
+ if (!consumerId) continue
312
+ const { expectsFrom } = parseTaskContracts(content)
313
+
314
+ for (const [providerTask, contracts] of Object.entries(expectsFrom)) {
315
+ if (!providesByTask.has(providerTask)) {
316
+ for (const c of contracts) {
317
+ errors.push(`${consumerId}: expects_from 引用了不存在的 ${providerTask}(contract "${c.contract}", needs [${c.needs.join(', ')}])`)
318
+ }
319
+ continue
320
+ }
321
+
322
+ const providerContracts = providesByTask.get(providerTask)
323
+ for (const c of contracts) {
324
+ const providerFields = providerContracts.get(c.contract)
325
+ if (providerFields === undefined) {
326
+ errors.push(`${consumerId}: expects_from ${providerTask} contract "${c.contract}" needs [${c.needs.join(', ')}] — ${providerTask} 的 provides 未声明此契约`)
327
+ continue
328
+ }
329
+ const missing = c.needs.filter(f => !providerFields.has(f))
330
+ if (missing.length > 0) {
331
+ errors.push(`${consumerId}: expects_from ${providerTask} contract "${c.contract}" needs [${missing.join(', ')}] — ${providerTask}.provides 仅含 [${[...providerFields].join(', ')}]`)
332
+ }
333
+ }
334
+ }
335
+ }
336
+
337
+ return { ok: errors.length === 0, errors, warnings }
338
+ }
339
+
340
+ // ── 路径匹配工具(与 change-list.js 的 normalizeEntry 同源语义)─────────
341
+ // allowed_paths 真实写法常带行内注释(`src/worktree.js (新增)`),匹配前必须归一化。
342
+ function normalizePath(raw) {
343
+ if (!raw) return ''
344
+ return raw
345
+ .replace(/`/g, '')
346
+ .replace(/\s*([^)]*)\s*$/, '')
347
+ .replace(/\s*\([^)]*\)\s*$/, '')
348
+ .replace(/\\/g, '/')
349
+ .replace(/\/+$/, '')
350
+ .trim()
351
+ }
352
+
353
+ function globMatch(str, pattern) {
354
+ if (!pattern.includes('*')) return false
355
+ const re = '^' + pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*') + '$'
356
+ try { return new RegExp(re).test(str) } catch { return false }
357
+ }
358
+
359
+ /**
360
+ * 双向容差匹配:design 清单文件 vs task allowed_paths
361
+ * 命中条件(任一):完全相等 / 目录前缀包含(双向)/ glob 通配(双向)
362
+ */
363
+ function pathMatches(designFile, allowedPath) {
364
+ const a = normalizePath(designFile)
365
+ const b = normalizePath(allowedPath)
366
+ if (!a || !b) return false
367
+ if (a === b) return true
368
+ if (a.startsWith(b + '/') || b.startsWith(a + '/')) return true
369
+ if (globMatch(a, b) || globMatch(b, a)) return true
370
+ return false
371
+ }
372
+
373
+ /**
374
+ * design.md 文件变更清单 → tasks allowed_paths 覆盖对账
375
+ *
376
+ * 修复场景:design.md 声明要改某源码文件(如 access-guide UI),但没有任何 task 的
377
+ * allowed_paths 覆盖它 → execute 子代理被 allowed_paths 锁死不能碰它 → UI 没改
378
+ * (「页面还是错的」),而 verify 一路对照 task 卡片循环验证照样 PASS。
379
+ *
380
+ * 在 plan-postcheck(execute 前)确定性拦截:design 清单中每个源码文件必须被
381
+ * 至少一个 task 的 allowed_paths 覆盖(前缀 / glob 容差匹配)。
382
+ *
383
+ * fail-open 边界(不阻断):design.md 不存在、无清单章节、解析为空、无 tasks/、
384
+ * 无 allowed_paths —— 保持与 none/light 级别及老变更向后兼容。
385
+ *
386
+ * @param {string} changeDir - 变更目录
387
+ * @returns {{ ok: boolean, errors: string[], warnings: string[], designFiles: string[], uncovered: string[] }}
388
+ */
389
+ export function validateDesignFileCoverage(changeDir) {
390
+ const errors = []
391
+ const warnings = []
392
+
393
+ const designPath = pJoin(changeDir, 'design.md')
394
+ if (!existsSync(designPath)) {
395
+ return { ok: true, errors, warnings, designFiles: [], uncovered: [] }
396
+ }
397
+
398
+ const designFiles = [...parseFileChangeList(designPath)]
399
+ if (designFiles.length === 0) {
400
+ warnings.push('design.md 未找到「文件变更清单」或清单为空,跳过文件覆盖对账')
401
+ return { ok: true, errors, warnings, designFiles: [], uncovered: [] }
402
+ }
403
+
404
+ const tasksDir = pJoin(changeDir, 'tasks')
405
+ if (!existsSync(tasksDir)) {
406
+ return { ok: true, errors, warnings, designFiles, uncovered: [] }
407
+ }
408
+
409
+ const taskFiles = readdirSync(tasksDir).filter(f => /^task-\d+\.md$/.test(f))
410
+ const allAllowed = []
411
+ for (const file of taskFiles) {
412
+ const content = readFileSync(pJoin(tasksDir, file), 'utf8')
413
+ allAllowed.push(...parseAllowedPaths(content))
414
+ }
415
+ if (allAllowed.length === 0) {
416
+ return { ok: true, errors, warnings, designFiles, uncovered: [] }
417
+ }
418
+
419
+ const uncovered = designFiles.filter(df => !allAllowed.some(ap => pathMatches(df, ap)))
420
+ if (uncovered.length > 0) {
421
+ errors.push(
422
+ `design.md 文件变更清单中 ${uncovered.length} 个文件未被任何 task 的 allowed_paths 覆盖:\n` +
423
+ uncovered.map(f => ` • ${f}`).join('\n') +
424
+ `\n 这些文件在 execute 阶段将无 task 有权修改 → 必然漏改。` +
425
+ `\n 修复:为每个遗漏文件新建/补充 task 并在其 allowed_paths 声明,` +
426
+ `或在 design.md「不修改文件」章节说明不改原因。`
427
+ )
428
+ }
429
+
430
+ return { ok: errors.length === 0, errors, warnings, designFiles, uncovered }
431
+ }
432
+
225
433
  /**
226
434
  * Plan 可行性校验器(本地代码证明 execute 前置条件)
227
435
  * 检查 TaskCard 的完整性和可行性
@@ -426,6 +634,32 @@ export async function executePlanPostcheck(context) {
426
634
  for (const w of feasibility.warnings) console.warn(` - ${w}`)
427
635
  }
428
636
 
637
+ // ── 1c. 跨任务契约校验 ──
638
+ // 对账 consumer.expects_from ↔ provider.provides,拦截「consumer 期望字段
639
+ // 但 provider 未承诺」的契约断裂(避免到 execute/verify 才暴露成 403/500)
640
+ const crossTask = validateCrossTaskContracts(changeDir)
641
+ if (crossTask.errors.length > 0) {
642
+ console.error('\n❌ 跨任务契约校验失败(consumer 期望的字段未被 provider 承诺):')
643
+ for (const err of crossTask.errors) console.error(` - ${err}`)
644
+ console.error('\n 修复方式:要么在 provider task 的 provides.fields 补上缺失字段,')
645
+ console.error(' 要么修正 consumer task 的 expects_from.needs(确认依赖是否真实)。')
646
+ throw new Error('planPostcheck: cross-task contract check failed')
647
+ }
648
+
649
+ // ── 1d. design 文件覆盖对账 ──
650
+ // design.md 清单中的每个源码文件必须被某 task 的 allowed_paths 覆盖,
651
+ // 否则 execute 子代理无权改它 → 漏改(典型表现:「页面还是错的」)。
652
+ const coverage = validateDesignFileCoverage(changeDir)
653
+ for (const w of coverage.warnings) console.warn(`\n ⚠️ ${w}`)
654
+ if (coverage.errors.length > 0) {
655
+ console.error('\n❌ design.md 文件覆盖对账失败(清单中的文件未被任何 task 覆盖):')
656
+ for (const err of coverage.errors) console.error(` - ${err}`)
657
+ throw new Error('planPostcheck: design file coverage check failed')
658
+ }
659
+ if (coverage.designFiles.length > 0 && coverage.uncovered.length === 0) {
660
+ console.log(` ✅ design.md ${coverage.designFiles.length} 个文件全部被 task allowed_paths 覆盖`)
661
+ }
662
+
429
663
  // ── 2. Wave 重排 ──
430
664
  const tasksDir = pJoin(changeDir, 'tasks')
431
665
  if (existsSync(tasksDir)) {
@@ -358,6 +358,8 @@ full 计划的约束:
358
358
  - [ ] plan.md 与 design.md 的文件变更清单一致
359
359
  - [ ] 如果涉及构造函数/接口/DTO/client 方法变更,是否搜索了所有调用点并纳入任务范围?
360
360
  - [ ] 调用点搜索命令的输出是否记录在 plan.md 或 task-NN.md 中?
361
+ - [ ] 跨任务契约自检:若 task-A 的产出(接口/DTO/响应)被 task-B 消费,consumer 是否在 TaskCard expects_from 里声明所需字段、provider 是否在 provides 里承诺这些字段、两边字段是否一致?(plan-postcheck 会硬校验,此处先自查)
362
+ - [ ] 文件覆盖自检:design.md「文件变更清单」中的每个源码文件,是否都被至少一个 task 的 allowed_paths 覆盖?(plan-postcheck 会硬校验,漏覆盖 = execute 必然漏改,此处先自查)
361
363
  - [ ] 如果有 Mermaid 图,依赖关系确实非平凡(非线性/非全并行)
362
364
  - [ ] 没有泛泛风险分析(如"需要充分测试")
363
365
 
@@ -407,6 +409,13 @@ requirement_ids: [FR-XX]
407
409
  decision_ids: [D-XXX@vN]
408
410
  allowed_paths:
409
411
  - frontend/src/lib/errors.ts
412
+ provides: # 可选。仅当本 task 给其他 task 提供接口/DTO/响应时填
413
+ - contract: <DTO或响应类型名> # 如 DaemonRuntimeRead
414
+ fields: [field_a, field_b]
415
+ expects_from: # 可选。仅当本 task 消费其他 task 的契约时填
416
+ <provider-task-id>: # 如 task-05(占位符,不要照抄)
417
+ - contract: <DTO或响应类型名>
418
+ needs: [field_a] # 必须从该 provider 拿到的字段
410
419
  goal: >
411
420
  一句话说明这个 task 要做什么、为什么。
412
421
  implementation:
@@ -433,6 +442,9 @@ TaskCard 格式规则(必须严格遵守):
433
442
  - verify: 列表,实际可执行的命令
434
443
  - constraints: 列表,明确边界(含 brownfield 兼容、异常处理)
435
444
  - 不需要:修改文件章节、覆盖来源章节、接口定义章节、TDD 步骤章节、参考章节
445
+ - provides / expects_from 是可选字段:仅当跨 task 契约(一个 task 的接口/DTO/响应被另一个 task 消费)时才填,单 task 或无对外接口场景留空即可
446
+ - 填写后 plan-postcheck 会做硬对账:consumer 的每个 expects_from[provider].needs 字段必须在对应 provider 的 provides.fields 里,否则 plan 阶段阻断(不进入 execute)
447
+ - 不要把内部实现字段塞进 provides;只暴露给其他 task 的对外契约形状
436
448
  - 如果存在 decisions.md,无法覆盖的 D-xxx@vN 在 constraints 中标注
437
449
  - 写完后用 Write tool 保存到文件
438
450
  \`\`\``
@@ -471,6 +483,7 @@ ${subagentPrompts}
471
483
  - **一致性自查**:
472
484
  - allowed_paths 有无冲突
473
485
  - depends_on 与 plan.md Wave 分组是否一致
486
+ - provides/expects_from 契约自洽:每个 expects_from[provider].needs 字段都在该 provider task 的 provides.fields 里(plan-postcheck 会硬校验,这里提前自查)
474
487
  - 如发现矛盾,列出问题清单,不要自动修复`
475
488
 
476
489
  return {
package/src/sync.js CHANGED
@@ -12,6 +12,16 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from '
12
12
  import { join } from 'path';
13
13
  import { resolvePlatformSpecDir } from './progress.js';
14
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
+ }
24
+
15
25
  const LOCAL_YAML = '.sillyspec/local.yaml';
16
26
  const CHANGES_DIR = '.sillyspec/changes';
17
27
  const REQUEST_TIMEOUT_MS = 10_000;
@@ -201,7 +211,7 @@ export class SyncManager {
201
211
  let progressData;
202
212
  try {
203
213
  const { ProgressManager } = await import('./progress.js');
204
- const pm = new ProgressManager({ specDir: resolvePlatformSpecDir(this.cwd) });
214
+ const pm = new ProgressManager({ specDir: safePlatformSpecDir(this.cwd) });
205
215
  progressData = await pm.read(this.cwd, changeName);
206
216
  } catch (err) {
207
217
  console.warn(`[sync] 读取 progress 失败 (${changeName}): ${err.message}`);
@@ -226,7 +236,7 @@ export class SyncManager {
226
236
  // 更新 platform_last_sync
227
237
  try {
228
238
  const { ProgressManager } = await import('./progress.js');
229
- const pm = new ProgressManager({ specDir: resolvePlatformSpecDir(this.cwd) });
239
+ const pm = new ProgressManager({ specDir: safePlatformSpecDir(this.cwd) });
230
240
  await pm._updatePlatformLastSync(this.cwd, changeName);
231
241
  } catch (err) {
232
242
  console.warn(`[sync] 更新 platform_last_sync 失败: ${err.message}`);
@@ -327,7 +337,7 @@ export class SyncManager {
327
337
  // 更新本地 approvals 表
328
338
  try {
329
339
  const { ProgressManager } = await import('./progress.js');
330
- const pm = new ProgressManager({ specDir: resolvePlatformSpecDir(this.cwd) });
340
+ const pm = new ProgressManager({ specDir: safePlatformSpecDir(this.cwd) });
331
341
  await pm._updateApprovalStatus(this.cwd, changeName, result.status, result.reason);
332
342
  } catch (err) {
333
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') {
@@ -1,64 +0,0 @@
1
- ---
2
- author: qinyi
3
- created_at: 2026-06-19 00:45:00
4
- ---
5
-
6
- # Brainstorm → Plan Contract
7
-
8
- ## 核心契约
9
-
10
- `design.md` 是 plan 阶段的**主要设计输入**。plan 不应该在空的或缺少关键决策的 design.md 上生成任务。
11
-
12
- ## design.md 结构要求
13
-
14
- ### 必须包含(error — 阻断 plan)
15
-
16
- | # | 章节 | 匹配关键词 |
17
- |---|------|-----------|
18
- | 1 | 目标/背景/问题描述 | 目标、goal、objective、背景、background、问题、problem、purpose、目的 |
19
- | 2 | 范围/总体方案/设计 | 范围、scope、总体方案、方案、approach、solution、设计、design |
20
- | 3 | 决策/方案选择 | 决策、decision、选择、choice、方案选择、D-xxx@vN(decisions.md 引用) |
21
-
22
- ### 建议包含(warning — 不阻断 plan)
23
-
24
- | # | 章节 | 匹配关键词 |
25
- |---|------|-----------|
26
- | 4 | 非目标/Non-goals | 非目标、non-goals、不做、out of scope |
27
- | 5 | 约束/风险/Trade-off | 约束、constraint、风险、risk、trade-off |
28
- | 6 | 文件变更清单 | 文件变更、变更清单、changed files |
29
-
30
- ## 校验规则
31
-
32
- plan 启动时(第一个步骤执行前)调用 `validateDesignForPlan(designContent)`:
33
-
34
- | 结果 | 行为 |
35
- |------|------|
36
- | 全部通过 | 正常进入 plan |
37
- | 有 warning | 继续执行,展示警告 |
38
- | 有 error | fail-fast,提示修复 design.md |
39
-
40
- ## 第一版设计原则
41
-
42
- - **轻量 markdown 契约**:检查标题和关键词,不强 schema
43
- - **关键词宽泛**:中英文都支持
44
- - **decisions.md 引用也算决策**:`D-xxx@vN` 或 `decisions.md` 引用即满足决策检查
45
- - **不做 brainstorm postcheck 阻断**:brainstorm 完成时不校验此契约(brainstorm 可以产出不完整的 design.md),只在 plan 启动时校验
46
-
47
- ## 错误处理
48
-
49
- | 场景 | 行为 |
50
- |------|------|
51
- | design.md 不存在 | 不校验(向后兼容,plan 可以独立运行) |
52
- | design.md 空 | fail-fast |
53
- | 缺目标/背景 | fail-fast |
54
- | 缺范围/方案 | fail-fast |
55
- | 缺决策 | fail-fast |
56
- | 缺非目标/约束/文件清单 | warning,继续执行 |
57
-
58
- ## 完整契约链
59
-
60
- ```
61
- brainstorm → design.md → [Plan Contract 校验] → plan → plan.md → [Execute Contract 校验] → execute
62
- ```
63
-
64
- 每个阶段启动前都校验上游产物,形成双重保险。