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.
- package/.claude/skills/sillyspec-doctor/SKILL.md +9 -0
- package/.playwright-mcp/console-2026-07-27T15-45-50-621Z.log +1 -0
- package/.playwright-mcp/page-2026-07-27T15-45-51-459Z.yml +144 -0
- package/.playwright-mcp/prompt-final.png +0 -0
- package/.playwright-mcp/prompt-view.png +0 -0
- package/CLAUDE.md +18 -0
- package/package.json +1 -1
- package/src/change-risk-profile.js +60 -12
- package/src/check-primitives.js +65 -0
- package/src/index.js +135 -6
- package/src/progress/change-registry.js +217 -0
- package/src/progress/consistency-doctor.js +259 -0
- package/src/progress/shared.js +33 -0
- package/src/progress/stage-machine.js +535 -0
- package/src/progress/step-store.js +256 -0
- package/src/progress.js +59 -1191
- package/src/quicklog.js +3 -1
- package/src/run/command.js +127 -50
- package/src/run/complete-handlers.js +2 -1
- package/src/run/complete.js +725 -695
- package/src/run/gates.js +326 -321
- package/src/run/prompt.js +619 -604
- package/src/run/quick-audit.js +107 -102
- package/src/run/shared.js +43 -0
- package/src/stage-contract-engine.js +150 -0
- package/src/stage-contract-spec.js +517 -0
- package/src/stage-contract.js +83 -148
- package/src/stage-review.js +63 -0
- package/src/stages/brainstorm.js +3 -1
- package/src/stages/execute.js +5 -2
- package/src/stages/plan-postcheck.js +38 -27
- package/src/stages/plan.js +12 -49
- package/src/stages/propose.js +2 -1
- package/src/stages/scan.js +3 -1
- package/src/workflow.js +9 -17
- package/src/worktree-apply.js +5 -2
- package/src/worktree-deps.js +114 -32
- package/src/worktree.js +6 -3
- 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})
|
|
246
|
+
errors.push(bsRule.data.messageAllowedPaths.replaceAll('${id}', `${taskId} (${file})`))
|
|
244
247
|
}
|
|
245
248
|
if (!hasAcceptance) {
|
|
246
|
-
errors.push(`${taskId} (${file})
|
|
249
|
+
errors.push(bsRule.data.messageAcceptance.replaceAll('${id}', `${taskId} (${file})`))
|
|
247
250
|
}
|
|
248
251
|
if (!hasTdd) {
|
|
249
|
-
warnings.push(`${taskId} (${file})
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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(
|
|
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(
|
|
464
|
-
if (!title) errors.push(
|
|
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(
|
|
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(
|
|
495
|
-
if (!hasImplementation) errors.push(
|
|
496
|
-
if (!hasAcceptance) errors.push(
|
|
497
|
-
if (!hasVerify) errors.push(
|
|
498
|
-
if (!hasConstraints) errors.push(
|
|
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(
|
|
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(
|
|
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
|
}
|
package/src/stages/plan.js
CHANGED
|
@@ -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.
|
|
26
|
+
return { ok: false, errors: [getRule('plan.design-readiness').data.emptyMessage], warnings }
|
|
26
27
|
}
|
|
27
28
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
361
|
-
|
|
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
|
### 输出
|
package/src/stages/propose.js
CHANGED
|
@@ -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
|
|
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
|
检查以下各项:
|
package/src/stages/scan.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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 })
|
package/src/worktree-apply.js
CHANGED
|
@@ -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}`) || '';
|
package/src/worktree-deps.js
CHANGED
|
@@ -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(
|
|
204
|
+
const userInstall = extractUserInstall(yamlText);
|
|
148
205
|
const installCmd = inferInstallCommand(projectType, worktreePath, userInstall);
|
|
149
206
|
|
|
150
|
-
|
|
207
|
+
let result;
|
|
151
208
|
if (!installCmd) {
|
|
152
|
-
|
|
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
|
-
//
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
178
|
-
|
|
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
|
-
// 排除
|
|
172
|
-
|
|
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}`) || '';
|