sillyspec 3.25.1 → 3.25.3

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.
@@ -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 迁移)
@@ -201,6 +202,34 @@ export function topoSortWaves(depMap) {
201
202
  return { waves, error: null }
202
203
  }
203
204
 
205
+ /**
206
+ * 解析 plan.md 显式 Wave 分组 → Map<taskId, waveIndex>。
207
+ *
208
+ * Wave 口径与 execute.js parseWavesFromPlan 一致(execute 的真实并行单元,非 topoSort 建议值):
209
+ * - `## Wave N` 标题下的 task-XX checkbox 归入 Wave N;
210
+ * - 非 Wave 标题行(## 自检 等)退出当前 Wave 段,避免其 checkbox 混入。
211
+ * 返回 null = plan.md 不存在或无显式 Wave 标题 → execute 会把所有 task 放进单个隐式 Wave
212
+ * 全并行(execute.js:402-408),调用方据此判「同文件即冲突」。
213
+ *
214
+ * @param {string} planPath - plan.md 绝对路径
215
+ * @returns {Map<string, number>|null} taskId → waveIndex;null = 无显式 Wave
216
+ */
217
+ function parseTaskWavesFromPlan(planPath) {
218
+ if (!existsSync(planPath)) return null
219
+ const content = readFileSync(planPath, 'utf8')
220
+ if (!/^#+\s*Wave\s+\d+/im.test(content)) return null
221
+ const map = new Map()
222
+ let currentWave = null
223
+ for (const line of content.split('\n')) {
224
+ const wm = line.match(/^#+\s*Wave\s+(\d+)/i)
225
+ if (wm) { currentWave = parseInt(wm[1], 10); continue }
226
+ if (/^#+\s/.test(line)) { currentWave = null; continue } // 非 Wave 标题退出当前 Wave 段
227
+ const tm = line.match(/^[-*]\s*\[[ x]\]\s*(task-\d+)/i)
228
+ if (tm && currentWave !== null) map.set(tm[1], currentWave)
229
+ }
230
+ return map
231
+ }
232
+
204
233
  /**
205
234
  * 本地一致性校验器
206
235
  * @param {string} changeDir - 变更目录
@@ -220,6 +249,8 @@ export function validateBlueprintConsistency(changeDir) {
220
249
  return { ok: false, errors: ['tasks/ 目录下没有 task-NN.md 文件'], warnings }
221
250
  }
222
251
 
252
+ // task 卡片基础字段文案从 manifest 同源(plan.task-card-structure);${id} 由下方 `${taskId} (${file})` 替换。
253
+ const bsRule = getRule('plan.task-card-structure')
223
254
  const taskInfo = new Map()
224
255
  const pathOwners = new Map()
225
256
 
@@ -240,13 +271,13 @@ export function validateBlueprintConsistency(changeDir) {
240
271
  taskInfo.set(taskId, { dependsOn, allowedPaths, hasAcceptance, hasTdd, file })
241
272
 
242
273
  if (allowedPaths.length === 0) {
243
- errors.push(`${taskId} (${file}): frontmatter 缺少 allowed_paths(需非空数组,列出本 task 真实改动的源文件;回归类 task 无源码改动时填被验证的关键入口文件)`)
274
+ errors.push(bsRule.data.messageAllowedPaths.replaceAll('${id}', `${taskId} (${file})`))
244
275
  }
245
276
  if (!hasAcceptance) {
246
- errors.push(`${taskId} (${file}): 缺少验收标准——frontmatter 需有 acceptance: 列表字段,或 body 需有「## 验收标准」/「## Acceptance」章节`)
277
+ errors.push(bsRule.data.messageAcceptance.replaceAll('${id}', `${taskId} (${file})`))
247
278
  }
248
279
  if (!hasTdd) {
249
- warnings.push(`${taskId} (${file}): 缺少验证步骤——frontmatter 需有 verify: 字段,或 body 需有「## TDD」/「## 验证」/「## Verify」章节`)
280
+ warnings.push(bsRule.data.messageTdd.replaceAll('${id}', `${taskId} (${file})`))
250
281
  }
251
282
 
252
283
  for (const p of allowedPaths) {
@@ -255,10 +286,40 @@ export function validateBlueprintConsistency(changeDir) {
255
286
  }
256
287
  }
257
288
 
258
- // 路径冲突
289
+ // 路径冲突(Wave 感知):同 Wave 内 >1 task 共享 allowed_path → execute 强制并行(execute.js:603)
290
+ // 子代理会互相覆盖该文件 → error。跨 Wave 同文件 → 串行执行安全 → warning。
291
+ // Wave 口径 = plan.md 显式 `## Wave N`(parseTaskWavesFromPlan,与 execute 同源,非 topoSort 建议值);
292
+ // plan.md 无显式 Wave → execute 全并行(隐式单 Wave,execute.js:402-408)→ 同文件即冲突。
293
+ const waveOfTask = parseTaskWavesFromPlan(pJoin(changeDir, 'plan.md'))
259
294
  for (const [p, owners] of pathOwners) {
260
- if (owners.length > 1) {
261
- warnings.push(`路径 ${p} 被 ${owners.length} 个 task 修改: ${owners.join(', ')}(确认是否为有意共享)`)
295
+ if (owners.length < 2) continue
296
+ if (waveOfTask === null) {
297
+ errors.push(
298
+ `路径 ${p} 被 ${owners.length} 个 task 修改: ${owners.join(', ')} — plan.md 无显式 Wave 划分,` +
299
+ `execute 会把它们放进同一隐式 Wave 全并行(execute.js:402-408),子代理互相覆盖。` +
300
+ `解法:拆到不同 Wave(串行),或合并为单个 task。`
301
+ )
302
+ continue
303
+ }
304
+ // 显式 Wave:按 wave 分组 owners,找同 Wave 冲突
305
+ const byWave = new Map()
306
+ for (const t of owners) {
307
+ const w = waveOfTask.get(t)
308
+ if (w === undefined) continue // 未列入任何 Wave → execute 不执行,不参与冲突判定
309
+ if (!byWave.has(w)) byWave.set(w, [])
310
+ byWave.get(w).push(t)
311
+ }
312
+ for (const [w, ts] of byWave) {
313
+ if (ts.length > 1) {
314
+ errors.push(
315
+ `路径 ${p} 被 Wave ${w} 内 ${ts.length} 个 task 修改: ${ts.join(', ')} — 同 Wave 任务 execute 强制并行(execute.js:603),` +
316
+ `子代理会互相覆盖该文件。解法:把它们拆到不同 Wave(串行)。`
317
+ )
318
+ }
319
+ }
320
+ // 跨 Wave 共享:串行安全,仅提示
321
+ if (byWave.size > 1) {
322
+ warnings.push(`路径 ${p} 跨 Wave 被修改: ${owners.join(', ')}(不同 Wave 串行执行,安全;确认是否有意共享)`)
262
323
  }
263
324
  }
264
325
 
@@ -314,7 +375,16 @@ export function validateCrossTaskContracts(changeDir) {
314
375
  providesByTask.set(taskId, contractMap)
315
376
  }
316
377
 
317
- // 第二遍:校验每个 consumer 的 expects_from 是否被 provider.provides 覆盖
378
+ // 第二遍:校验每个 consumer 的 expects_from 是否被 provider.provides 覆盖。
379
+ // 3 种断裂文案(unknown-provider / undeclared / missing-fields)从 manifest 同源
380
+ // (plan.cross-task-contract.data),${consumer}/${provider}/${contract}/${needs}/${available} replaceAll。
381
+ const ctRule = getRule('plan.cross-task-contract')
382
+ const renderCtMsg = (tmpl, consumer, provider, contract, needs, available) => tmpl
383
+ .replaceAll('${consumer}', consumer)
384
+ .replaceAll('${provider}', provider)
385
+ .replaceAll('${contract}', contract)
386
+ .replaceAll('${needs}', needs)
387
+ .replaceAll('${available}', available)
318
388
  for (const file of taskFiles) {
319
389
  const content = readFileSync(pJoin(tasksDir, file), 'utf8')
320
390
  const consumerId = parseTaskId(content, file)
@@ -324,7 +394,7 @@ export function validateCrossTaskContracts(changeDir) {
324
394
  for (const [providerTask, contracts] of Object.entries(expectsFrom)) {
325
395
  if (!providesByTask.has(providerTask)) {
326
396
  for (const c of contracts) {
327
- errors.push(`${consumerId}: expects_from 引用了不存在的 ${providerTask}(contract "${c.contract}", needs [${c.needs.join(', ')}])`)
397
+ errors.push(renderCtMsg(ctRule.data.messageUnknownProvider, consumerId, providerTask, c.contract, c.needs.join(', '), ''))
328
398
  }
329
399
  continue
330
400
  }
@@ -333,12 +403,12 @@ export function validateCrossTaskContracts(changeDir) {
333
403
  for (const c of contracts) {
334
404
  const providerFields = providerContracts.get(c.contract)
335
405
  if (providerFields === undefined) {
336
- errors.push(`${consumerId}: expects_from ${providerTask} contract "${c.contract}" needs [${c.needs.join(', ')}] — ${providerTask} 的 provides 未声明此契约`)
406
+ errors.push(renderCtMsg(ctRule.data.messageUndeclaredContract, consumerId, providerTask, c.contract, c.needs.join(', '), ''))
337
407
  continue
338
408
  }
339
409
  const missing = c.needs.filter(f => !providerFields.has(f))
340
410
  if (missing.length > 0) {
341
- errors.push(`${consumerId}: expects_from ${providerTask} contract "${c.contract}" needs [${missing.join(', ')}] — ${providerTask}.provides 仅含 [${[...providerFields].join(', ')}]`)
411
+ errors.push(renderCtMsg(ctRule.data.messageMissingFields, consumerId, providerTask, c.contract, missing.join(', '), [...providerFields].join(', ')))
342
412
  }
343
413
  }
344
414
  }
@@ -388,13 +458,12 @@ export function validateDesignFileCoverage(changeDir) {
388
458
  }
389
459
 
390
460
  const designFiles = [...parseFileChangeList(designPath)]
461
+ // 两种断裂文案(缺清单章节 / 文件未覆盖)从 manifest 同源(plan.design-file-coverage.data)。
462
+ const dcRule = getRule('plan.design-file-coverage')
391
463
  if (designFiles.length === 0) {
392
464
  // 走到 plan-postcheck 说明已生成 task 卡片(light/full),brainstorm 模板规定清单必填。
393
465
  // 无清单 = design↔execute 偏差温床(覆盖对账无从对起),阻断,不让它静默放过。
394
- errors.push(
395
- 'design.md 缺少「文件变更清单」章节(或清单解析为空),无法做文件覆盖对账。' +
396
- '该章节在 brainstorm 模板中为必填;请在 design.md 补上完整的文件变更清单(列出本次新增/修改/删除的源码文件)后重试。'
397
- )
466
+ errors.push(dcRule.data.messageMissingList)
398
467
  return { ok: false, errors, warnings, designFiles: [], uncovered: [] }
399
468
  }
400
469
  const allAllowed = []
@@ -409,11 +478,9 @@ export function validateDesignFileCoverage(changeDir) {
409
478
  const uncovered = designFiles.filter(df => !allAllowed.some(ap => pathMatches(df, ap)))
410
479
  if (uncovered.length > 0) {
411
480
  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「不修改文件」章节说明不改原因。`
481
+ dcRule.data.messageUncovered
482
+ .replaceAll('${count}', uncovered.length)
483
+ .replaceAll('${files}', uncovered.map(f => ` • ${f}`).join('\n'))
417
484
  )
418
485
  }
419
486
 
@@ -441,6 +508,8 @@ export function validatePlanFeasibility(changeDir, projectRoot = null) {
441
508
  return { ok: true, errors, warnings }
442
509
  }
443
510
 
511
+ // task 卡片完整 schema 文案从 manifest 同源(plan.task-card-schema);${id} 随检查点取 ${file} / ${taskId || file} / ${taskId}。
512
+ const fsRule = getRule('plan.task-card-schema')
444
513
  const allTaskIds = []
445
514
  const depMap = new Map()
446
515
 
@@ -448,7 +517,7 @@ export function validatePlanFeasibility(changeDir, projectRoot = null) {
448
517
  const content = readFileSync(pJoin(tasksDir, file), 'utf8')
449
518
  const fmMatch = content.match(/^---\n([\s\S]*?)\n---/)
450
519
  if (!fmMatch) {
451
- errors.push(`${file}: 缺少 YAML frontmatter`)
520
+ errors.push(fsRule.data.messageFrontmatter.replaceAll('${id}', file))
452
521
  continue
453
522
  }
454
523
  const fm = fmMatch[1]
@@ -460,13 +529,13 @@ export function validatePlanFeasibility(changeDir, projectRoot = null) {
460
529
  const allowedPathsRaw = fm.match(/allowed_paths:\s*\n((?:\s+-\s+.+\n?)+)/)?.[1] || ''
461
530
  const allowedPaths = allowedPathsRaw.match(/-\s+(.+)/g)?.map(s => s.replace(/^-\s+/, '').trim()) || []
462
531
 
463
- if (!taskId) errors.push(`${file}: frontmatter 缺少 id`)
464
- if (!title) errors.push(`${file}: frontmatter 缺少 title`)
532
+ if (!taskId) errors.push(fsRule.data.messageId.replaceAll('${id}', file))
533
+ if (!title) errors.push(fsRule.data.messageTitle.replaceAll('${id}', file))
465
534
  if (taskId) allTaskIds.push(taskId)
466
535
 
467
536
  // 2. allowed_paths 不为空
468
537
  if (allowedPaths.length === 0) {
469
- errors.push(`${taskId || file}: allowed_paths 为空`)
538
+ errors.push(fsRule.data.messageAllowedPaths.replaceAll('${id}', taskId || file))
470
539
  }
471
540
 
472
541
  // 3. allowed_paths 文件存在或父目录存在(仅在 projectRoot 提供时检查)
@@ -491,18 +560,18 @@ export function validatePlanFeasibility(changeDir, projectRoot = null) {
491
560
  const hasVerify = /verify:/m.test(fm)
492
561
  const hasConstraints = /constraints:/m.test(fm)
493
562
 
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 字段`)
563
+ if (!hasGoal) errors.push(fsRule.data.messageGoal.replaceAll('${id}', taskId || file))
564
+ if (!hasImplementation) errors.push(fsRule.data.messageImplementation.replaceAll('${id}', taskId || file))
565
+ if (!hasAcceptance) errors.push(fsRule.data.messageAcceptance.replaceAll('${id}', taskId || file))
566
+ if (!hasVerify) errors.push(fsRule.data.messageVerify.replaceAll('${id}', taskId || file))
567
+ if (!hasConstraints) errors.push(fsRule.data.messageConstraints.replaceAll('${id}', taskId || file))
499
568
  }
500
569
 
501
570
  // 4b. depends_on 引用存在性
502
571
  for (const [taskId, deps] of depMap) {
503
572
  for (const dep of deps) {
504
573
  if (!depMap.has(dep)) {
505
- errors.push(`${taskId}: depends_on 引用了不存在的 ${dep}`)
574
+ errors.push(fsRule.data.messageDependsOnMissing.replaceAll('${id}', taskId).replaceAll('${dep}', dep))
506
575
  }
507
576
  }
508
577
  }
@@ -522,7 +591,7 @@ export function validatePlanFeasibility(changeDir, projectRoot = null) {
522
591
  if (nums.length > 0 && nums[0] === 1) {
523
592
  for (let i = 0; i < nums.length; i++) {
524
593
  if (nums[i] !== i + 1) {
525
- errors.push(`task id 不连续: 期望 task-${String(i + 1).padStart(2, '0')}, 实际 task-${String(nums[i]).padStart(2, '0')}`)
594
+ 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
595
  break
527
596
  }
528
597
  }
@@ -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,17 @@ 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
+ // 排除非交付物的元数据/文档 churn(execute 自身改的 + 多操作者常改的 agent 指引/文档),
189
+ // 否则别人改 CLAUDE.md/docs/.claude → 整树 hash 变 → apply 误阻断(多操作者仓库高频踩坑)。
190
+ // 安全:放过它们不丢真冲突——下方 step5a(未提交∩changedFiles)+step5b(HEAD blob) 对交付文件
191
+ // 做精确 per-file 校验,不读这里的 exclude。
192
+ // - .sillyspec/:plan/design 蓝图 + runtime 产物
193
+ // - .claude/:agent 配置/skills/CLAUDE.md
194
+ // - docs/:文档(deliverable 文档冲突由 step5a/5b 兜底)
195
+ // - CLAUDE.md:根 agent 指引
188
196
  if (meta.baselineHash) {
189
- const exclude = '-- . ":(exclude).sillyspec/"';
197
+ const exclude = '-- . ":(exclude).sillyspec/" ":(exclude).claude/" ":(exclude)docs/" ":(exclude)CLAUDE.md"';
190
198
  const staged = gitQuiet(projectRoot, `diff --cached ${exclude}`) || '';
191
199
  const unstaged = gitQuiet(projectRoot, `diff ${exclude}`) || '';
192
200
  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
  }