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.
- 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/package.json +2 -2
- package/src/change-list.js +51 -16
- package/src/contract-matrix.js +67 -0
- package/src/doctor-diagnostics.js +575 -0
- package/src/hooks/worktree-guard.js +111 -111
- package/src/index.js +154 -82
- package/src/progress.js +59 -9
- package/src/run.js +139 -38
- package/src/stage-contract.js +14 -2
- package/src/stages/execute.js +37 -18
- package/src/stages/plan-postcheck.js +234 -0
- package/src/stages/plan.js +13 -0
- package/src/sync.js +13 -3
- package/src/worktree.js +69 -28
- package/docs/brainstorm-plan-contract.md +0 -64
- package/docs/plan-execute-contract.md +0 -123
- package/docs/platform-scan-protocol.md +0 -298
- package/docs/revision-mode.md +0 -115
- package/docs/sillyspec/file-lifecycle/known-implementation-gaps.md +0 -99
- package/docs/sillyspec/file-lifecycle/platform-workflows-sync.md +0 -223
- package/docs/sillyspec/file-lifecycle/stage-artifacts.md +0 -167
- package/docs/sillyspec/file-lifecycle/storage-and-state.md +0 -148
- package/docs/sillyspec/file-lifecycle/worktree-and-guard.md +0 -211
- package/docs/sillyspec/file-lifecycle.md +0 -132
- package/docs/workflow-contract-regression.md +0 -106
- package/docs/worktree-isolation.md +0 -252
- package/test/brainstorm-plan-contract.test.mjs +0 -273
- package/test/check-syntax.mjs +0 -26
- package/test/cli-top-level-aliases.test.mjs +0 -174
- package/test/contract-artifacts.test.mjs +0 -323
- package/test/decision-ref-version.mjs +0 -85
- package/test/decision-supersede.test.mjs +0 -277
- package/test/knowledge-match.test.mjs +0 -231
- package/test/plan-execute-contract.test.mjs +0 -357
- package/test/plan-optimization.test.mjs +0 -572
- package/test/platform-artifacts.test.mjs +0 -190
- package/test/platform-failure-samples.test.mjs +0 -199
- package/test/platform-recovery-chain.test.mjs +0 -179
- package/test/platform-recovery.test.mjs +0 -167
- package/test/platform-scan-p0.test.mjs +0 -186
- package/test/quick-recommend.test.mjs +0 -146
- package/test/revision-v1.test.mjs +0 -1145
- package/test/run-sanitize-project-name.test.mjs +0 -51
- package/test/run-scan-postcheck-fail.test.mjs +0 -64
- package/test/run-scan-project-parse.test.mjs +0 -200
- package/test/run-tests.mjs +0 -48
- package/test/runtime-cleanup-keeps-worktree.test.mjs +0 -107
- package/test/scan-docs-yaml-placeholders.test.mjs +0 -84
- package/test/scan-knowledge.test.mjs +0 -175
- package/test/scan-paths.test.mjs +0 -68
- package/test/scan-postcheck-project-priority.test.mjs +0 -85
- package/test/scan-postcheck.test.mjs +0 -197
- package/test/scan-workflow-anyfailed-block.test.mjs +0 -52
- package/test/spec-dir.test.mjs +0 -206
- package/test/stage-contract-failed-post-check.test.mjs +0 -102
- package/test/stage-contract.test.mjs +0 -301
- package/test/stage-definitions.test.mjs +0 -39
- package/test/wait-gates.test.mjs +0 -501
- package/test/workflow-spec-base.test.mjs +0 -142
- package/test/worktree-deps-provision.test.mjs +0 -148
- package/test/worktree-guard.test.mjs +0 -136
- 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)) {
|
package/src/stages/plan.js
CHANGED
|
@@ -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:
|
|
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:
|
|
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:
|
|
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
|
|
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建议:
|
|
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
|
-
//
|
|
566
|
-
if (!force) {
|
|
567
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
700
|
-
|
|
701
|
-
|
|
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
|
|
766
|
-
if (fix &&
|
|
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
|
-
每个阶段启动前都校验上游产物,形成双重保险。
|