sillyspec 3.25.1 → 3.25.2

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 (39) hide show
  1. package/.claude/skills/sillyspec-doctor/SKILL.md +9 -0
  2. package/.playwright-mcp/console-2026-07-27T15-45-50-621Z.log +1 -0
  3. package/.playwright-mcp/page-2026-07-27T15-45-51-459Z.yml +144 -0
  4. package/.playwright-mcp/prompt-final.png +0 -0
  5. package/.playwright-mcp/prompt-view.png +0 -0
  6. package/CLAUDE.md +18 -0
  7. package/package.json +1 -1
  8. package/src/change-risk-profile.js +60 -12
  9. package/src/check-primitives.js +65 -0
  10. package/src/index.js +135 -6
  11. package/src/progress/change-registry.js +217 -0
  12. package/src/progress/consistency-doctor.js +259 -0
  13. package/src/progress/shared.js +33 -0
  14. package/src/progress/stage-machine.js +535 -0
  15. package/src/progress/step-store.js +256 -0
  16. package/src/progress.js +59 -1191
  17. package/src/quicklog.js +3 -1
  18. package/src/run/command.js +127 -50
  19. package/src/run/complete-handlers.js +2 -1
  20. package/src/run/complete.js +725 -695
  21. package/src/run/gates.js +326 -321
  22. package/src/run/prompt.js +619 -604
  23. package/src/run/quick-audit.js +107 -102
  24. package/src/run/shared.js +43 -0
  25. package/src/stage-contract-engine.js +150 -0
  26. package/src/stage-contract-spec.js +517 -0
  27. package/src/stage-contract.js +83 -148
  28. package/src/stage-review.js +63 -0
  29. package/src/stages/brainstorm.js +3 -1
  30. package/src/stages/execute.js +5 -2
  31. package/src/stages/plan-postcheck.js +38 -27
  32. package/src/stages/plan.js +12 -49
  33. package/src/stages/propose.js +2 -1
  34. package/src/stages/scan.js +3 -1
  35. package/src/workflow.js +9 -17
  36. package/src/worktree-apply.js +5 -2
  37. package/src/worktree-deps.js +114 -32
  38. package/src/worktree.js +6 -3
  39. package/src/brainstorm-postcheck.js +0 -158
@@ -17,6 +17,7 @@ const readFileSync = (filePath, encoding) => _readFileSync(filePath, encoding).r
17
17
  import { join as pJoin } from 'path'
18
18
  import jsYaml from 'js-yaml'
19
19
  import { parseFileChangeList, pathMatches } from '../change-list.js'
20
+ import { getRule } from '../stage-contract-spec.js'
20
21
 
21
22
  // ═══════════════════════════════════════════════════════════════
22
23
  // 解析工具(从 plan.js 迁移)
@@ -220,6 +221,8 @@ export function validateBlueprintConsistency(changeDir) {
220
221
  return { ok: false, errors: ['tasks/ 目录下没有 task-NN.md 文件'], warnings }
221
222
  }
222
223
 
224
+ // task 卡片基础字段文案从 manifest 同源(plan.task-card-structure);${id} 由下方 `${taskId} (${file})` 替换。
225
+ const bsRule = getRule('plan.task-card-structure')
223
226
  const taskInfo = new Map()
224
227
  const pathOwners = new Map()
225
228
 
@@ -240,13 +243,13 @@ export function validateBlueprintConsistency(changeDir) {
240
243
  taskInfo.set(taskId, { dependsOn, allowedPaths, hasAcceptance, hasTdd, file })
241
244
 
242
245
  if (allowedPaths.length === 0) {
243
- errors.push(`${taskId} (${file}): frontmatter 缺少 allowed_paths(需非空数组,列出本 task 真实改动的源文件;回归类 task 无源码改动时填被验证的关键入口文件)`)
246
+ errors.push(bsRule.data.messageAllowedPaths.replaceAll('${id}', `${taskId} (${file})`))
244
247
  }
245
248
  if (!hasAcceptance) {
246
- errors.push(`${taskId} (${file}): 缺少验收标准——frontmatter 需有 acceptance: 列表字段,或 body 需有「## 验收标准」/「## Acceptance」章节`)
249
+ errors.push(bsRule.data.messageAcceptance.replaceAll('${id}', `${taskId} (${file})`))
247
250
  }
248
251
  if (!hasTdd) {
249
- warnings.push(`${taskId} (${file}): 缺少验证步骤——frontmatter 需有 verify: 字段,或 body 需有「## TDD」/「## 验证」/「## Verify」章节`)
252
+ warnings.push(bsRule.data.messageTdd.replaceAll('${id}', `${taskId} (${file})`))
250
253
  }
251
254
 
252
255
  for (const p of allowedPaths) {
@@ -314,7 +317,16 @@ export function validateCrossTaskContracts(changeDir) {
314
317
  providesByTask.set(taskId, contractMap)
315
318
  }
316
319
 
317
- // 第二遍:校验每个 consumer 的 expects_from 是否被 provider.provides 覆盖
320
+ // 第二遍:校验每个 consumer 的 expects_from 是否被 provider.provides 覆盖。
321
+ // 3 种断裂文案(unknown-provider / undeclared / missing-fields)从 manifest 同源
322
+ // (plan.cross-task-contract.data),${consumer}/${provider}/${contract}/${needs}/${available} replaceAll。
323
+ const ctRule = getRule('plan.cross-task-contract')
324
+ const renderCtMsg = (tmpl, consumer, provider, contract, needs, available) => tmpl
325
+ .replaceAll('${consumer}', consumer)
326
+ .replaceAll('${provider}', provider)
327
+ .replaceAll('${contract}', contract)
328
+ .replaceAll('${needs}', needs)
329
+ .replaceAll('${available}', available)
318
330
  for (const file of taskFiles) {
319
331
  const content = readFileSync(pJoin(tasksDir, file), 'utf8')
320
332
  const consumerId = parseTaskId(content, file)
@@ -324,7 +336,7 @@ export function validateCrossTaskContracts(changeDir) {
324
336
  for (const [providerTask, contracts] of Object.entries(expectsFrom)) {
325
337
  if (!providesByTask.has(providerTask)) {
326
338
  for (const c of contracts) {
327
- errors.push(`${consumerId}: expects_from 引用了不存在的 ${providerTask}(contract "${c.contract}", needs [${c.needs.join(', ')}])`)
339
+ errors.push(renderCtMsg(ctRule.data.messageUnknownProvider, consumerId, providerTask, c.contract, c.needs.join(', '), ''))
328
340
  }
329
341
  continue
330
342
  }
@@ -333,12 +345,12 @@ export function validateCrossTaskContracts(changeDir) {
333
345
  for (const c of contracts) {
334
346
  const providerFields = providerContracts.get(c.contract)
335
347
  if (providerFields === undefined) {
336
- errors.push(`${consumerId}: expects_from ${providerTask} contract "${c.contract}" needs [${c.needs.join(', ')}] — ${providerTask} 的 provides 未声明此契约`)
348
+ errors.push(renderCtMsg(ctRule.data.messageUndeclaredContract, consumerId, providerTask, c.contract, c.needs.join(', '), ''))
337
349
  continue
338
350
  }
339
351
  const missing = c.needs.filter(f => !providerFields.has(f))
340
352
  if (missing.length > 0) {
341
- errors.push(`${consumerId}: expects_from ${providerTask} contract "${c.contract}" needs [${missing.join(', ')}] — ${providerTask}.provides 仅含 [${[...providerFields].join(', ')}]`)
353
+ errors.push(renderCtMsg(ctRule.data.messageMissingFields, consumerId, providerTask, c.contract, missing.join(', '), [...providerFields].join(', ')))
342
354
  }
343
355
  }
344
356
  }
@@ -388,13 +400,12 @@ export function validateDesignFileCoverage(changeDir) {
388
400
  }
389
401
 
390
402
  const designFiles = [...parseFileChangeList(designPath)]
403
+ // 两种断裂文案(缺清单章节 / 文件未覆盖)从 manifest 同源(plan.design-file-coverage.data)。
404
+ const dcRule = getRule('plan.design-file-coverage')
391
405
  if (designFiles.length === 0) {
392
406
  // 走到 plan-postcheck 说明已生成 task 卡片(light/full),brainstorm 模板规定清单必填。
393
407
  // 无清单 = design↔execute 偏差温床(覆盖对账无从对起),阻断,不让它静默放过。
394
- errors.push(
395
- 'design.md 缺少「文件变更清单」章节(或清单解析为空),无法做文件覆盖对账。' +
396
- '该章节在 brainstorm 模板中为必填;请在 design.md 补上完整的文件变更清单(列出本次新增/修改/删除的源码文件)后重试。'
397
- )
408
+ errors.push(dcRule.data.messageMissingList)
398
409
  return { ok: false, errors, warnings, designFiles: [], uncovered: [] }
399
410
  }
400
411
  const allAllowed = []
@@ -409,11 +420,9 @@ export function validateDesignFileCoverage(changeDir) {
409
420
  const uncovered = designFiles.filter(df => !allAllowed.some(ap => pathMatches(df, ap)))
410
421
  if (uncovered.length > 0) {
411
422
  errors.push(
412
- `design.md 文件变更清单中 ${uncovered.length} 个文件未被任何 task 的 allowed_paths 覆盖:\n` +
413
- uncovered.map(f => ` • ${f}`).join('\n') +
414
- `\n 这些文件在 execute 阶段将无 task 有权修改 → 必然漏改。` +
415
- `\n 修复:为每个遗漏文件新建/补充 task 并在其 allowed_paths 声明,` +
416
- `或在 design.md「不修改文件」章节说明不改原因。`
423
+ dcRule.data.messageUncovered
424
+ .replaceAll('${count}', uncovered.length)
425
+ .replaceAll('${files}', uncovered.map(f => ` • ${f}`).join('\n'))
417
426
  )
418
427
  }
419
428
 
@@ -441,6 +450,8 @@ export function validatePlanFeasibility(changeDir, projectRoot = null) {
441
450
  return { ok: true, errors, warnings }
442
451
  }
443
452
 
453
+ // task 卡片完整 schema 文案从 manifest 同源(plan.task-card-schema);${id} 随检查点取 ${file} / ${taskId || file} / ${taskId}。
454
+ const fsRule = getRule('plan.task-card-schema')
444
455
  const allTaskIds = []
445
456
  const depMap = new Map()
446
457
 
@@ -448,7 +459,7 @@ export function validatePlanFeasibility(changeDir, projectRoot = null) {
448
459
  const content = readFileSync(pJoin(tasksDir, file), 'utf8')
449
460
  const fmMatch = content.match(/^---\n([\s\S]*?)\n---/)
450
461
  if (!fmMatch) {
451
- errors.push(`${file}: 缺少 YAML frontmatter`)
462
+ errors.push(fsRule.data.messageFrontmatter.replaceAll('${id}', file))
452
463
  continue
453
464
  }
454
465
  const fm = fmMatch[1]
@@ -460,13 +471,13 @@ export function validatePlanFeasibility(changeDir, projectRoot = null) {
460
471
  const allowedPathsRaw = fm.match(/allowed_paths:\s*\n((?:\s+-\s+.+\n?)+)/)?.[1] || ''
461
472
  const allowedPaths = allowedPathsRaw.match(/-\s+(.+)/g)?.map(s => s.replace(/^-\s+/, '').trim()) || []
462
473
 
463
- if (!taskId) errors.push(`${file}: frontmatter 缺少 id`)
464
- if (!title) errors.push(`${file}: frontmatter 缺少 title`)
474
+ if (!taskId) errors.push(fsRule.data.messageId.replaceAll('${id}', file))
475
+ if (!title) errors.push(fsRule.data.messageTitle.replaceAll('${id}', file))
465
476
  if (taskId) allTaskIds.push(taskId)
466
477
 
467
478
  // 2. allowed_paths 不为空
468
479
  if (allowedPaths.length === 0) {
469
- errors.push(`${taskId || file}: allowed_paths 为空`)
480
+ errors.push(fsRule.data.messageAllowedPaths.replaceAll('${id}', taskId || file))
470
481
  }
471
482
 
472
483
  // 3. allowed_paths 文件存在或父目录存在(仅在 projectRoot 提供时检查)
@@ -491,18 +502,18 @@ export function validatePlanFeasibility(changeDir, projectRoot = null) {
491
502
  const hasVerify = /verify:/m.test(fm)
492
503
  const hasConstraints = /constraints:/m.test(fm)
493
504
 
494
- if (!hasGoal) errors.push(`${taskId || file}: 缺少 goal 字段`)
495
- if (!hasImplementation) errors.push(`${taskId || file}: 缺少 implementation 字段`)
496
- if (!hasAcceptance) errors.push(`${taskId || file}: 缺少 acceptance 字段`)
497
- if (!hasVerify) errors.push(`${taskId || file}: 缺少 verify 字段`)
498
- if (!hasConstraints) errors.push(`${taskId || file}: 缺少 constraints 字段`)
505
+ if (!hasGoal) errors.push(fsRule.data.messageGoal.replaceAll('${id}', taskId || file))
506
+ if (!hasImplementation) errors.push(fsRule.data.messageImplementation.replaceAll('${id}', taskId || file))
507
+ if (!hasAcceptance) errors.push(fsRule.data.messageAcceptance.replaceAll('${id}', taskId || file))
508
+ if (!hasVerify) errors.push(fsRule.data.messageVerify.replaceAll('${id}', taskId || file))
509
+ if (!hasConstraints) errors.push(fsRule.data.messageConstraints.replaceAll('${id}', taskId || file))
499
510
  }
500
511
 
501
512
  // 4b. depends_on 引用存在性
502
513
  for (const [taskId, deps] of depMap) {
503
514
  for (const dep of deps) {
504
515
  if (!depMap.has(dep)) {
505
- errors.push(`${taskId}: depends_on 引用了不存在的 ${dep}`)
516
+ errors.push(fsRule.data.messageDependsOnMissing.replaceAll('${id}', taskId).replaceAll('${dep}', dep))
506
517
  }
507
518
  }
508
519
  }
@@ -522,7 +533,7 @@ export function validatePlanFeasibility(changeDir, projectRoot = null) {
522
533
  if (nums.length > 0 && nums[0] === 1) {
523
534
  for (let i = 0; i < nums.length; i++) {
524
535
  if (nums[i] !== i + 1) {
525
- errors.push(`task id 不连续: 期望 task-${String(i + 1).padStart(2, '0')}, 实际 task-${String(nums[i]).padStart(2, '0')}`)
536
+ errors.push(getRule('plan.task-id-continuity').failMessage.replaceAll('${expected}', String(i + 1).padStart(2, '0')).replaceAll('${actual}', String(nums[i]).padStart(2, '0')))
526
537
  break
527
538
  }
528
539
  }
@@ -1,5 +1,6 @@
1
1
  import { existsSync, readFileSync, readdirSync } from 'fs'
2
2
  import path from 'path'
3
+ import { getRule } from '../stage-contract-spec.js'
3
4
 
4
5
  // 从 plan-postcheck.js 重导出(保持向后兼容)
5
6
  export {
@@ -22,48 +23,17 @@ export function validateDesignForPlan(designContent) {
22
23
  const warnings = []
23
24
 
24
25
  if (!designContent || !designContent.trim()) {
25
- return { ok: false, errors: ['design.md 内容为空'], warnings }
26
+ return { ok: false, errors: [getRule('plan.design-readiness').data.emptyMessage], warnings }
26
27
  }
27
28
 
28
- const lower = designContent.toLowerCase()
29
-
30
- // 检查 1: 必须包含目标/问题描述(error)
31
- const hasGoal = /(^|\n)#{2,}\s*.*(目标|goal|objective|背景|background|问题|problem|purpose|目的)/i.test(designContent)
32
- if (!hasGoal) {
33
- errors.push('design.md 缺少「目标/背景/问题描述」章节 — plan 需要知道要达成什么')
34
- }
35
-
36
- // 检查 2: 必须包含范围/scope(error)
37
- const hasScope = /(^|\n)#{2,}\s*.*(范围|scope|总体方案|方案|approach|solution|设计|design)/i.test(designContent)
38
- if (!hasScope) {
39
- errors.push('design.md 缺少「范围/总体方案/设计」章节 — plan 需要知道做什么和怎么做')
40
- }
41
-
42
- // 检查 3: 必须包含决策/方案选择(error)
43
- const hasDecisions = /(^|\n)#{2,}\s*.*(决策|decision|选择|choice|方案选择)/i.test(designContent)
44
- || /d-\d+@v\d+/i.test(designContent) // decisions.md 引用 ID
45
- || /decisions?\.md/i.test(designContent) // 引用 decisions.md
46
- if (!hasDecisions) {
47
- errors.push('design.md 缺少「决策/方案选择」— plan 需要基于明确的技术决策来拆分任务')
48
- }
49
-
50
- // 检查 4 (warning): 缺非目标/non-goals
51
- const hasNonGoals = /(^|\n)#{2,}\s*.*(非目标|non-goals?|不做|out of scope|不在范围)/i.test(designContent)
52
- if (!hasNonGoals) {
53
- warnings.push('design.md 缺少「非目标/Non-goals」— 建议明确不做什么,防止 scope creep')
54
- }
55
-
56
- // 检查 5 (warning): 缺约束/风险
57
- const hasConstraints = /(^|\n)#{2,}\s*.*(约束|constraint|限制|limitation|风险|risk|trade-?off)/i.test(designContent)
58
- if (!hasConstraints) {
59
- warnings.push('design.md 缺少「约束/风险/Trade-off」— 建议记录已知约束和风险')
60
- }
61
-
62
- // 检查 6 (warning): 缺文件变更清单
63
- const hasFileChanges = /文件变更|file change|变更清单|changed files/i.test(designContent)
64
- || /^\|\s*(新增|修改|删除|new|modify|delete|update)\s*\|/im.test(designContent)
65
- if (!hasFileChanges) {
66
- warnings.push('design.md 缺少「文件变更清单」— 建议列出预期改动的文件')
29
+ // 6 章节就绪检查(patterns + message)从 manifest 同源(stage-contract-spec.js plan.design-readiness);
30
+ // 逐章节 test,patterns 数组任一命中即视为有该章节。算法等价旧内联正则(原 lower 变量未使用,已弃)。
31
+ const rule = getRule('plan.design-readiness')
32
+ for (const check of rule.data.checks) {
33
+ const matched = check.patterns.some(p => new RegExp(p.pattern, p.flags).test(designContent))
34
+ if (!matched) {
35
+ (check.severity === 'error' ? errors : warnings).push(check.message)
36
+ }
67
37
  }
68
38
 
69
39
  return { ok: errors.length === 0, errors, warnings }
@@ -357,15 +327,8 @@ tier: {REVIEW_TIER}({REVIEW_TIER_REASON})
357
327
  用 Agent tool 启动子代理(subagent_type: general),prompt 要点:
358
328
  1. 独立读取 {SPEC_ROOT}/changes/<change>/plan.md + design.md + tasks/*.md(不要让生成者喂结论给你,自己读原始文件)
359
329
  2. 执行上方审查清单,每条给 pass/gap/fail + 证据
360
- 3. 输出 review.json 到 {SPEC_ROOT}/.runtime/stage-reviews/plan-{STAGE_REVIEW_RUN_ID}/review.json,字段:
361
- - schemaVersion: 1
362
- - reviewType: plan ; stage: plan
363
- - specVerdict / qualityVerdict: pass | fail | cannot_verify
364
- - reviewedFiles: [changes/<change>/plan.md]
365
- - docHash: plan.md 内容的 sha256
366
- - checklist: 每条 { item, result: pass|gap|fail, note }
367
- - requiredEvidence: cannot_verify 时必填非空
368
- - reviewerNotes: 说明
330
+ 3. 输出 review.json(CLI Stage Review Gate 将硬校验,契约如下 —— schema + 完整示例 + docHash 算法,照抄改值):
331
+ {REVIEW_JSON_CONTRACT}
369
332
  4. verdict=fail 时在 reviewerNotes 写明阻断项
370
333
 
371
334
  ### 输出
@@ -143,7 +143,8 @@ export const definition = {
143
143
  ### 当前审查分级(CLI 按变更规模判定,占位符由 run.js 注入)
144
144
  tier: {REVIEW_TIER}({REVIEW_TIER_REASON})
145
145
  - tier=self:当前 agent 直接执行下方 checklist
146
- - tier=independent:必须用 Agent tool 启动一个独立的规范审查子代理(独立上下文,不共享你的分析),子代理执行下方 checklist 并输出 review.json 到 {SPEC_ROOT}/.runtime/stage-reviews/propose-{STAGE_REVIEW_RUN_ID}/review.json。字段:reviewType=proposal;reviewedFiles=[proposal.md, design.md, requirements.md, tasks.md](相对 changes/<change>/);docHash=proposal.md 的 sha256;specVerdict/qualityVerdict=pass|fail|cannot_verify;checklist 每条 pass|gap|fail。
146
+ - tier=independent:必须用 Agent tool 启动一个独立的规范审查子代理(独立上下文,不共享你的分析),子代理执行下方 checklist 并输出 review.json。review.json 产物契约(CLI Stage Review Gate 将硬校验,schema + 完整示例 + docHash 算法如下,照抄改值):
147
+ {REVIEW_JSON_CONTRACT}
147
148
 
148
149
  ### 操作
149
150
  检查以下各项:
@@ -143,6 +143,8 @@ export const definition = {
143
143
  - 生成每份 scan 文档时,frontmatter 必须包含:
144
144
  \`\`\`yaml
145
145
  ---
146
+ author: <git 用户名(git config user.name 的输出,如 qinyi)>
147
+ created_at: <精确到秒的时间,如 2026-07-27 10:30:00>
146
148
  source_commit: <git-head-short>
147
149
  updated_at: <now-iso-datetime>
148
150
  generator: sillyspec-scan
@@ -159,7 +161,7 @@ export const definition = {
159
161
  - 断点续扫步骤列出的缺失文档列表
160
162
  - 环境探测结果摘要(如有 _env-detect.md,直接贴入)
161
163
  - **⚠️ 必须强调:子代理必须用 write 工具将文件写入磁盘**
162
- - **文件标题用中文**(sillyhub 平台解析识别用):每份 scan 文档第一行用 # 中文名(English)格式:
164
+ - **文件标题用中文**(sillyhub 平台解析识别用):frontmatter 必须在文件最前(第 1 行 --- 起),frontmatter 结束的 --- 之后空一行,再写 # 中文名(English)作为标题。严禁把 # 标题放到 frontmatter 之前(否则 frontmatter 不在头部、Step11 检查项9 会报缺 author/created_at)。各文档标准标题格式:
163
165
  - STRUCTURE.md = # 目录结构(Structure)
164
166
  - CONVENTIONS.md = # 代码约定(Conventions)
165
167
  - ARCHITECTURE.md = # 架构(Architecture)
package/src/workflow.js CHANGED
@@ -13,6 +13,7 @@ import { readFileSync, existsSync, readdirSync, writeFileSync, mkdirSync } from
13
13
  import { join, resolve, basename } from 'path'
14
14
  import jsYaml from 'js-yaml'
15
15
  import { WORKFLOW_STATUS } from './constants.js'
16
+ import { contentNonEmpty, lineCount, missingSections, placeholderLineMatches } from './check-primitives.js'
16
17
 
17
18
  // ─── Workflow 加载 ───
18
19
 
@@ -174,7 +175,7 @@ function checkOutput(outputDef, projectName, cwd, specBase) {
174
175
  case 'no_empty_files': {
175
176
  if (existsSync(fullPath)) {
176
177
  const content = readFileSync(fullPath, 'utf8')
177
- const empty = content.trim().length === 0
178
+ const empty = !contentNonEmpty(content)
178
179
  results.push({ passed: !empty, check: 'no_empty_files', detail: empty ? `文件为空: ${rawPath}` : '' })
179
180
  } else {
180
181
  results.push({ passed: false, check: 'no_empty_files', detail: `文件不存在: ${rawPath}` })
@@ -184,7 +185,7 @@ function checkOutput(outputDef, projectName, cwd, specBase) {
184
185
  case 'min_lines': {
185
186
  if (existsSync(fullPath)) {
186
187
  const content = readFileSync(fullPath, 'utf8')
187
- const lines = content.split('\n').length
188
+ const lines = lineCount(content)
188
189
  const min = check.min || 1
189
190
  results.push({ passed: lines >= min, check: `min_lines(${min})`, detail: lines >= min ? '' : `文件只有 ${lines} 行,要求至少 ${min} 行: ${rawPath}` })
190
191
  } else {
@@ -196,7 +197,7 @@ function checkOutput(outputDef, projectName, cwd, specBase) {
196
197
  if (existsSync(fullPath)) {
197
198
  const content = readFileSync(fullPath, 'utf8')
198
199
  const sections = check.sections || []
199
- const missing = sections.filter(s => !content.includes(`## ${s}`))
200
+ const missing = missingSections(content, sections)
200
201
  results.push({ passed: missing.length === 0, check: 'contains_sections', detail: missing.length > 0 ? `缺少章节: ${missing.join(', ')} — ${rawPath}` : '' })
201
202
  } else {
202
203
  results.push({ passed: false, check: 'contains_sections', detail: `文件不存在: ${rawPath}` })
@@ -206,12 +207,7 @@ function checkOutput(outputDef, projectName, cwd, specBase) {
206
207
  case 'no_placeholder': {
207
208
  if (existsSync(fullPath)) {
208
209
  const content = readFileSync(fullPath, 'utf8')
209
- const patterns = check.patterns || ['待补充', 'TODO', 'TBD', '未分析', '根据项目情况', '根据实际情况', '按需填写']
210
- // 只匹配独立成行的占位文本,不匹配行内引用
211
- const lineMatches = patterns.filter(p => {
212
- const regex = new RegExp(`^\\s*[-*]?\\s*${p}\\s*$`, 'm')
213
- return regex.test(content)
214
- })
210
+ const lineMatches = placeholderLineMatches(content, check.patterns)
215
211
  results.push({ passed: lineMatches.length === 0, check: 'no_placeholder', detail: lineMatches.length > 0 ? `包含占位文本: ${lineMatches.map(m => `"${m}"`).join(', ')} — ${rawPath}` : '' })
216
212
  } else {
217
213
  results.push({ passed: false, check: 'no_placeholder', detail: `文件不存在: ${rawPath}` })
@@ -355,7 +351,7 @@ function _checkWorkflow(wf, cwd, projectName, specBase) {
355
351
  let anyEmpty = false
356
352
  for (const f of files) {
357
353
  const content = readFileSync(join(scanDir, f), 'utf8')
358
- if (content.trim().length === 0) {
354
+ if (!contentNonEmpty(content)) {
359
355
  const detail = `空文件: ${join(scanDir, f)}`
360
356
  workflowCheckResults.push({ type: 'no_empty_files', status: 'fail', detail })
361
357
  failures.push({ level: 'workflow', check: 'no_empty_files', message: detail })
@@ -396,7 +392,7 @@ function _checkWorkflow(wf, cwd, projectName, specBase) {
396
392
  const fullPath = join(effectiveBase, checkPath)
397
393
  if (existsSync(fullPath)) {
398
394
  const content = readFileSync(fullPath, 'utf8')
399
- const lines = content.split('\n').length
395
+ const lines = lineCount(content)
400
396
  const min = check.min || 1
401
397
  if (lines < min) {
402
398
  const detail = `文件只有 ${lines} 行,要求至少 ${min} 行: ${checkPath}`
@@ -420,7 +416,7 @@ function _checkWorkflow(wf, cwd, projectName, specBase) {
420
416
  if (existsSync(fullPath)) {
421
417
  const content = readFileSync(fullPath, 'utf8')
422
418
  const sections = check.sections || []
423
- const missing = sections.filter(s => !content.includes(`## ${s}`))
419
+ const missing = missingSections(content, sections)
424
420
  if (missing.length > 0) {
425
421
  const detail = `缺少章节: ${missing.join(', ')} — ${checkPath}`
426
422
  workflowCheckResults.push({ type: 'contains_sections', status: 'fail', detail })
@@ -442,11 +438,7 @@ function _checkWorkflow(wf, cwd, projectName, specBase) {
442
438
  const fullPath = join(effectiveBase, checkPath)
443
439
  if (existsSync(fullPath)) {
444
440
  const content = readFileSync(fullPath, 'utf8')
445
- const patterns = check.patterns || ['待补充', 'TODO', 'TBD', '未分析', '根据项目情况', '根据实际情况', '按需填写']
446
- const lineMatches = patterns.filter(p => {
447
- const regex = new RegExp(`^\\s*[-*]?\\s*${p}\\s*$`, 'm')
448
- return regex.test(content)
449
- })
441
+ const lineMatches = placeholderLineMatches(content, check.patterns)
450
442
  if (lineMatches.length > 0) {
451
443
  const detail = `包含占位文本: ${lineMatches.map(m => `"${m}"`).join(', ')} — ${checkPath}`
452
444
  workflowCheckResults.push({ type: 'no_placeholder', status: 'fail', detail })
@@ -184,9 +184,12 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
184
184
  }
185
185
 
186
186
  // --- 4.5 校验:主工作区 baseline 是否变化(防 execute 期间主工作区被修改)---
187
- // 注意:必须和 computeBaselineHash (worktree.js) 使用相同的排除规则
187
+ // 注意:必须和 computeBaselineHash (worktree.js) 使用相同的排除规则。
188
+ // 排除 execute 流程自身会改的元数据(否则每个 execute 收尾都因自身改动 baseline 漂移 BLOCKED):
189
+ // - .sillyspec/:plan/design 蓝图 + runtime 产物
190
+ // - docs/sillyspec/:工具坑文件(CLAUDE.md 规则要求 execute 期间记录)
188
191
  if (meta.baselineHash) {
189
- const exclude = '-- . ":(exclude).sillyspec/"';
192
+ const exclude = '-- . ":(exclude).sillyspec/" ":(exclude)docs/sillyspec/"';
190
193
  const staged = gitQuiet(projectRoot, `diff --cached ${exclude}`) || '';
191
194
  const unstaged = gitQuiet(projectRoot, `diff ${exclude}`) || '';
192
195
  const untracked = gitQuiet(projectRoot, `ls-files --others --exclude-standard ${exclude}`) || '';
@@ -131,55 +131,137 @@ function tryInstall(cmd, cwd, timeout) {
131
131
  }
132
132
  }
133
133
 
134
+ /** 目录是否含 nodejs 标记(package.json 或任一 lockfile)——识别 monorepo 里的 nodejs 子模块 */
135
+ function hasNodeMarker(dir) {
136
+ if (!dir || !existsSync(dir)) return false;
137
+ if (existsSync(join(dir, 'package.json'))) return true;
138
+ return LOCKFILES.some(lf => existsSync(join(dir, lf)));
139
+ }
140
+
141
+ /**
142
+ * 从 local.yaml 提取 modules 块的 path 列表(generic monorepo 子模块路径)。
143
+ * 兼容 inline flow(`frontend: { path: "frontend/" }`)与 block(`frontend:\n path: frontend/`)。
144
+ * 轻量正则,不引 yaml 依赖(与 extractUserInstall / scan-postcheck 风格一致)。
145
+ * 坑 execute-worktree-pnpm-monorepo-no-node-modules:generic 项目 worktree 子模块无 node_modules。
146
+ * @returns {string[]} 去重 + 去 trailing 斜杠的相对路径
147
+ */
148
+ function extractModulePaths(yamlText) {
149
+ if (!yamlText) return [];
150
+ const idx = yamlText.search(/^modules:\s*$/m);
151
+ if (idx < 0) return [];
152
+ const nlIdx = yamlText.indexOf('\n', idx);
153
+ if (nlIdx < 0) return [];
154
+ let rest = yamlText.slice(nlIdx + 1);
155
+ const nextTop = rest.search(/^\S/m); // 下一个顶层 key(行首非空白)
156
+ const block = nextTop >= 0 ? rest.slice(0, nextTop) : rest;
157
+ const paths = [];
158
+ const re = /path:\s*["']?([^"'\s,}]+)/g;
159
+ let pm;
160
+ while ((pm = re.exec(block)) !== null) {
161
+ const p = pm[1].replace(/\/+$/, '').trim();
162
+ if (p && p !== '.') paths.push(p);
163
+ }
164
+ return [...new Set(paths)];
165
+ }
166
+
167
+ /**
168
+ * 对单个子模块目录 tryLink main 的 node_modules → wt 的 node_modules(modules 子模块专用)。
169
+ * 仅走 link 快路径(不 install——子模块 install 慢且易失败;lockfile 不一致时交给用户 pnpm install)。
170
+ * lockfile 一致才 link,避免误链不匹配的 deps。
171
+ */
172
+ function linkOneDir(wtDir, mainDir) {
173
+ const mainNodeModules = join(mainDir, 'node_modules');
174
+ if (!existsSync(mainNodeModules)) return { status: 'skipped', reason: 'main 无 node_modules' };
175
+ const wtHash = lockfileHash(wtDir);
176
+ const mainHash = lockfileHash(mainDir);
177
+ if (wtHash && mainHash && wtHash !== mainHash) {
178
+ return { status: 'mismatch', reason: `lockfile 不一致 main=${mainHash} wt=${wtHash}` };
179
+ }
180
+ const r = tryLink(mainNodeModules, join(wtDir, 'node_modules'));
181
+ if (r.ok) return { status: 'linked', method: r.method };
182
+ return { status: 'failed', error: r.error };
183
+ }
184
+
134
185
  /**
135
- * 供给依赖。返回 deps 状态对象(合并进 meta)。
186
+ * 供给依赖。返回 deps 状态对象(合并进 meta)。两段:
187
+ * 1. 根目录供给(原有逻辑:lockfile 一致 link / 不一致 install / generic→根 n/a)
188
+ * 2. modules 块的 nodejs 子模块 link(generic monorepo:根无 deps 但 frontend/daemon 等子模块需要)
189
+ * generic + 子模块 link 成功时,整体 depsStatus 从 n/a 升级为 linked(deps gate 不再误判 unknown 阻断)。
190
+ *
136
191
  * @param {string} worktreePath - worktree 根目录
137
192
  * @param {string} mainCwd - 主 checkout 根目录(node_modules 来源)
138
193
  * @param {{ specBase?: string, timeout?: number }} opts
139
- * @returns {{ depsStatus, depsMethod, depsSource, depsLockHash, depsCheckedAt, depsError? }}
194
+ * @returns {{ depsStatus, depsMethod, depsSource, depsLockHash, depsCheckedAt, depsError?, depsModules? }}
140
195
  */
141
196
  export function provisionDeps(worktreePath, mainCwd, opts = {}) {
142
197
  const { specBase = null, timeout = DEFAULT_TIMEOUT_MS } = opts;
143
198
  const depsCheckedAt = new Date().toISOString();
199
+ const yamlText = readLocalYaml(specBase, worktreePath);
144
200
  const wtHash = lockfileHash(worktreePath);
145
201
 
202
+ // ── 1. 根目录供给 ──
146
203
  const projectType = detectProjectType(worktreePath, specBase);
147
- const userInstall = extractUserInstall(readLocalYaml(specBase, worktreePath));
204
+ const userInstall = extractUserInstall(yamlText);
148
205
  const installCmd = inferInstallCommand(projectType, worktreePath, userInstall);
149
206
 
150
- // generic / 无可执行 install → n/a
207
+ let result;
151
208
  if (!installCmd) {
152
- return { depsStatus: 'n/a', depsMethod: null, depsSource: null, depsLockHash: wtHash, depsCheckedAt };
209
+ // generic / 无可执行 install → 根无 deps 动作(n/a);但下方 modules 子模块仍可能 link 升级
210
+ result = { depsStatus: 'n/a', depsMethod: null, depsSource: null, depsLockHash: wtHash };
211
+ } else {
212
+ // 快路径:main 有 node_modules 且 lockfile hash 一致 → junction/symlink
213
+ const mainNodeModules = mainCwd ? join(mainCwd, 'node_modules') : null;
214
+ const mainHash = lockfileHash(mainCwd);
215
+ let linked = false;
216
+ if (mainNodeModules && existsSync(mainNodeModules) && mainHash && wtHash && mainHash === wtHash) {
217
+ const linkResult = tryLink(mainNodeModules, join(worktreePath, 'node_modules'));
218
+ if (linkResult.ok) {
219
+ result = {
220
+ depsStatus: 'linked',
221
+ depsMethod: linkResult.method,
222
+ depsSource: linkResult.preexisting ? 'install' : 'main-checkout',
223
+ depsLockHash: wtHash,
224
+ };
225
+ linked = true;
226
+ }
227
+ }
228
+ if (!linked) {
229
+ // 兜底:install
230
+ const installResult = tryInstall(installCmd, worktreePath, timeout);
231
+ result = installResult.ok
232
+ ? { depsStatus: 'installed', depsMethod: 'install', depsSource: 'install', depsLockHash: wtHash }
233
+ : { depsStatus: 'failed', depsMethod: null, depsSource: null, depsLockHash: wtHash, depsError: installResult.error };
234
+ }
153
235
  }
236
+ result.depsCheckedAt = depsCheckedAt;
154
237
 
155
- // 快路径:main 有 node_modules 且 lockfile hash 一致 → junction/symlink
156
- const mainNodeModules = mainCwd ? join(mainCwd, 'node_modules') : null;
157
- const mainHash = lockfileHash(mainCwd);
158
- if (mainNodeModules && existsSync(mainNodeModules) && mainHash && wtHash && mainHash === wtHash) {
159
- const linkResult = tryLink(mainNodeModules, join(worktreePath, 'node_modules'));
160
- if (linkResult.ok) {
161
- return {
162
- depsStatus: 'linked',
163
- depsMethod: linkResult.method,
164
- depsSource: linkResult.preexisting ? 'install' : 'main-checkout',
165
- depsLockHash: wtHash,
166
- depsCheckedAt,
167
- };
168
- }
169
- // link 失败 → 回退 install
238
+ // ── 2. modules 块的 nodejs 子模块 link(坑 execute-worktree-pnpm-monorepo-no-node-modules)──
239
+ // generic monorepo(如 multi-agent-platform)worktree 子模块 frontend/sillyhub-daemon 无 node_modules,
240
+ // 跑 pnpm test 失败。读 local.yaml modules 块,对 nodejs 子模块 tryLink main 的 node_modules。
241
+ const modulePaths = extractModulePaths(yamlText);
242
+ const moduleResults = [];
243
+ for (const mp of modulePaths) {
244
+ const wtDir = join(worktreePath, mp);
245
+ const mainDir = mainCwd ? join(mainCwd, mp) : null;
246
+ if (!existsSync(wtDir) || !mainDir || !hasNodeMarker(wtDir)) continue;
247
+ moduleResults.push({ path: mp, ...linkOneDir(wtDir, mainDir) });
170
248
  }
171
249
 
172
- // 兜底:install
173
- const installResult = tryInstall(installCmd, worktreePath, timeout);
174
- if (installResult.ok) {
175
- return { depsStatus: 'installed', depsMethod: 'install', depsSource: 'install', depsLockHash: wtHash, depsCheckedAt };
250
+ if (moduleResults.length > 0) {
251
+ result.depsModules = moduleResults;
252
+ const linkedModules = moduleResults.filter(m => m.status === 'linked');
253
+ if (linkedModules.length > 0 && result.depsStatus === 'n/a') {
254
+ // 标 generic 但子模块 link 成功 → 升级为 linked(deps gate 不再误判 unknown 阻断 execute)
255
+ result.depsStatus = 'linked';
256
+ result.depsMethod = linkedModules[0].method;
257
+ result.depsSource = 'main-checkout';
258
+ }
259
+ const failedModules = moduleResults.filter(m => m.status === 'failed');
260
+ if (failedModules.length > 0) {
261
+ const fmsg = failedModules.map(m => `${m.path}: ${m.error}`).join('; ');
262
+ result.depsError = result.depsError ? `${result.depsError}; ${fmsg}` : fmsg;
263
+ }
176
264
  }
177
- return {
178
- depsStatus: 'failed',
179
- depsMethod: null,
180
- depsSource: null,
181
- depsLockHash: wtHash,
182
- depsCheckedAt,
183
- depsError: installResult.error,
184
- };
265
+
266
+ return result;
185
267
  }
package/src/worktree.js CHANGED
@@ -167,9 +167,12 @@ function sleepMs(ms) {
167
167
  while (Date.now() < end) { /* spin */ }
168
168
  }
169
169
 
170
- function computeBaselineHash(cwd) {
171
- // 排除 .sillyspec/ 元数据目录,避免 brainstorm/plan 阶段修改的蓝图文件污染 baseline
172
- const exclude = '-- . ":(exclude).sillyspec/"';
170
+ export function computeBaselineHash(cwd) {
171
+ // 排除 execute 流程自身会改的元数据,避免漂移误报(否则每个 execute 收尾都因自身改动 BLOCKED):
172
+ // - .sillyspec/:brainstorm/plan 蓝图 + runtime 产物
173
+ // - docs/sillyspec/:工具坑文件(CLAUDE.md 规则要求 execute 期间记录)
174
+ // 必须和 applyWorktree step 4.5 (worktree-apply.js) 使用相同的排除规则。
175
+ const exclude = '-- . ":(exclude).sillyspec/" ":(exclude)docs/sillyspec/"';
173
176
  const staged = gitQuiet(cwd, `diff --cached ${exclude}`) || '';
174
177
  const unstaged = gitQuiet(cwd, `diff ${exclude}`) || '';
175
178
  const untracked = gitQuiet(cwd, `ls-files --others --exclude-standard ${exclude}`) || '';