sillyspec 3.26.15 → 3.27.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 (47) hide show
  1. package/.claude/skills/sillyspec-explore/SKILL.md +10 -3
  2. package/.claude/skills/sillyspec-verify/SKILL.md +2 -0
  3. package/README.md +2 -2
  4. package/package.json +1 -1
  5. package/packages/dashboard/server/index.js +22 -6
  6. package/src/agent-session-log.js +763 -0
  7. package/src/change-list.js +22 -2
  8. package/src/change-risk-profile.js +21 -6
  9. package/src/contract-matrix.js +91 -7
  10. package/src/db.js +14 -3
  11. package/src/docs-check.js +598 -570
  12. package/src/doctor-diagnostics.js +53 -1
  13. package/src/endpoint-extractor.js +21 -2
  14. package/src/fs-atomic.js +4 -2
  15. package/src/git-helper.js +73 -5
  16. package/src/hooks/claude-pre-tool-use.cjs +10 -5
  17. package/src/hooks/worktree-guard.js +22 -3
  18. package/src/index.js +59 -7
  19. package/src/module-impact.js +21 -1
  20. package/src/module-resolve.js +211 -0
  21. package/src/modules.js +82 -2
  22. package/src/progress/step-store.js +12 -8
  23. package/src/progress.js +6 -2
  24. package/src/quicklog.js +11 -3
  25. package/src/run/command.js +40 -0
  26. package/src/run/complete-handlers.js +17 -2
  27. package/src/run/complete.js +81 -8
  28. package/src/run/gates.js +83 -14
  29. package/src/run/prompt.js +16 -2
  30. package/src/run/quick-audit.js +22 -2
  31. package/src/run/shared.js +70 -19
  32. package/src/run/stage.js +60 -3
  33. package/src/stage-contract.js +14 -1
  34. package/src/stages/archive.js +3 -1
  35. package/src/stages/execute.js +171 -18
  36. package/src/stages/explore.js +11 -2
  37. package/src/stages/plan-postcheck.js +3 -1
  38. package/src/stages/plan.js +3 -0
  39. package/src/stages/quick.js +4 -3
  40. package/src/stages/verify.js +9 -2
  41. package/src/sync.js +98 -3
  42. package/src/task-review.js +55 -7
  43. package/src/taskcard.js +52 -22
  44. package/src/verify-postcheck.js +223 -6
  45. package/src/worktree-apply.js +28 -10
  46. package/src/worktree-deps.js +71 -5
  47. package/src/worktree.js +70 -6
@@ -7,6 +7,10 @@ import { isPathASupported } from '../dispatch/backends/sillyhub-mcp.js'
7
7
  import { readMcpConfig } from '../sillyhub-mcp/config.js'
8
8
  import { gitQuiet } from '../git-helper.js'
9
9
  import { parseRepo } from './plan-postcheck.js'
10
+ // 模块卡分级解析(token 成本优化 P0a)。注意:module-resolve.js 反向 import 本文件的
11
+ // parseTaskRegistry(注册表同口径)——双向引用均为函数声明 + 调用时解引(无模块初始化期
12
+ // 交叉求值),ESM live binding 下安全;两边都是纯读函数,无副作用序问题。
13
+ import { resolveChangeModuleCards } from '../module-resolve.js'
10
14
 
11
15
  /**
12
16
  * 任务注册表解析(2026-08-20-task-truth-unify D-001@v1:tasks.md 唯一任务真相)。
@@ -91,7 +95,26 @@ export function validatePlanForExecute(tasksContent, planContent) {
91
95
  const waves = parseWavesFromPlan(plan, registry)
92
96
  const allTasks = registry
93
97
 
94
- // 检查 0.8: Wave 标题格式不对(W1/Wave1/波次1 等)→ 引用行不被收容,静默退化隐式单 Wave
98
+ // 检查 0.6: plan_level 锚点缺失(2026-08-21 审查 CLI-5):plan_level 是 review tier 的
99
+ // 判定主锚(classifyReviewTier)——漏写时静默退文件数启发式,full 级大变更会被降级成
100
+ // self 自审且无人报错(降级方向比 fail 更危险)。与 gates 读侧同口径:任意行 plan_level:。
101
+ const fmLine = plan.split('\n').find(l => l.trim().startsWith('plan_level:'))
102
+ if (!fmLine) {
103
+ errors.push(
104
+ `plan.md 缺 plan_level 锚点。brainstorm 的规模判定落盘(frontmatter 写 plan_level: none|light|full)是 review 分级的主锚,` +
105
+ `漏写会静默退文件数启发式——full 级变更可能被降级自审。修复:在 plan.md frontmatter 补一行,如 plan_level: full`
106
+ )
107
+ } else {
108
+ const planLevelValue = fmLine.split(':')[1].trim()
109
+ if (!['none', 'light', 'full'].includes(planLevelValue)) {
110
+ errors.push(
111
+ `plan_level 值非法:${JSON.stringify(planLevelValue)}(合法值 none|light|full)。` +
112
+ `非法值在 review 分级中被当作无锚点处理,同样会静默退文件数启发式`
113
+ )
114
+ }
115
+ }
116
+
117
+ // 检查 0.8: Wave 标题格式不对(W1/波次1 等)→ 引用行不被收容,静默退化隐式单 Wave
95
118
  // 全并行——串行意图失效且无提示(原「标题格式不对」诊断的新契约承接)。仅在确有疑似标题
96
119
  // 且无任何显式 Wave 被解析时报,正常 Wave 10/带括号标题不误伤。
97
120
  const hasExplicitWave = waves.some(w => !w.implicit)
@@ -102,6 +125,22 @@ export function validatePlanForExecute(tasksContent, planContent) {
102
125
  `其下的 "- task-XX" 引用行不会被收容——任务将退化为单个隐式 Wave 全并行,串行意图失效`
103
126
  )
104
127
  }
128
+ // 检查 0.9(坑 wave-heading-undercount,2026-08-21 实证):部分 Wave 被识别 + 部分
129
+ // wave-like 标题漏识别(6 标题解析 5 个)→ 漏掉 Wave 的任务静默落入后续步骤,收尾靠
130
+ // 批量完成兜住才没出事。逐个点名漏网标题,plan --done 即阻断修复。
131
+ if (hasExplicitWave) {
132
+ const strictRe = /^#+\s*Wave\s*\d+/i
133
+ const missed = plan.split('\n')
134
+ .map(l => l.replace(/\r$/, ''))
135
+ .filter(l => waveLikeHeading.test(l) && !strictRe.test(l))
136
+ if (missed.length > 0) {
137
+ errors.push(
138
+ `存在 ${missed.length} 个「疑似 Wave 标题但未被识别」的行(其余 Wave 已正常解析,这 ${missed.length} 个段的任务会漏进后续步骤):` +
139
+ missed.slice(0, 5).map(l => `"${l.trim().slice(0, 50)}"`).join('、') +
140
+ `。必须字面 "## Wave N" 格式(Wave+空格+数字),修正后重跑 plan --done`
141
+ )
142
+ }
143
+ }
105
144
 
106
145
  // 检查 1: 注册表非空(tasks.md 无 task-XX checkbox → 三类根因诊断)
107
146
  if (allTasks.length === 0) {
@@ -202,18 +241,19 @@ const fixedPrefix = [
202
241
  4. 读取 local.yaml(构建命令);若 local.yaml 不存在,先 \`sillyspec local detect\` 生成骨架再读取
203
242
  5. 加载项目总览 \`.sillyspec/docs/<project>/scan/PROJECT.md\`(如存在)
204
243
 
205
- ### 模块文档加载
206
- 6. 读取 \`.sillyspec/docs/<project>/modules/_module-map.yaml\`(不存在则跳过以下步骤)
207
- 7. 根据 plan.md 中的任务文件路径匹配 _module-map.yaml 中的模块
208
- 8. 读取匹配到的 \`.sillyspec/docs/<project>/modules/<module>.md\`
209
- 9. 实现代码时遵循模块文档中描述的接口约定、数据流和依赖关系
210
- 10. **利用模块索引快速定位源码**:
244
+ ### 模块文档加载(细粒度卡优先——CLI 已按 tasks 卡 allowed_paths 级联匹配)
245
+ 6. 读取下方注入的 per-task 模块卡表({MODULE_RESOLVE_TABLE})——跨全部 _module-map.yaml 最长前缀匹配,子项目细粒度卡优先于根层大卡
246
+ 7. 主代理与子代理**只读表中命中的卡**:细粒度卡整卡可读(体量小、语义近);仅粗粒度大卡命中时**按节读**——只读「契约摘要」「注意事项」「定位」,跳过「变更索引」「人工备注」等历史累积段
247
+ 8. 实现代码时遵循模块卡中描述的接口约定、数据流和依赖关系
248
+ 9. **利用模块索引快速定位源码**:
211
249
  - 用 entrypoints 字段直接找到模块对外 API 的源码位置
212
250
  - 用 main_symbols 字段找到核心类/函数的定义位置
213
251
  - 子代理优先读模块卡片理解语义,再读 entrypoints/main_symbols 对应的源码
252
+ 10. 表查询命令同源可复跑:\`sillyspec modules resolve --change <change-name>\`(任务卡改动后重跑刷新表)
214
253
 
215
254
  ### 符号影响面扩展检查
216
255
  11. **符号影响面扫描**(Critical — execute 前必做):
256
+ - **重入沿用(省重复消耗)**:若 symbol-impact.md 已存在、结论完整且其文件头「tasks.md 内容指纹」与当前 tasks.md 一致(中断续跑/阶段重开场景),直接复核沿用既有结论,**不重做调用点扫描**
217
257
  - **报告骨架勿手写**:先跑 \`sillyspec symbol-impact --change <change-name>\`——CLI 从 tasks.md 生成逐 task \`<!--TODO-->\` 骨架(gate 拦截时也会自动落一份);把每行占位替换为真实结论(**未替换的 TODO 占位会被 gate 拒绝**,骨架不能直接过门)
218
258
  - 读取所有 tasks/task-NN.md,提取每个任务涉及的修改文件
219
259
  - 对每个修改文件,检查是否涉及以下变更类型:
@@ -282,7 +322,7 @@ worktree 路径 + 分支名 + 模式
282
322
  {DOCS_DEBT}
283
323
 
284
324
  如上为 CLI 用 git 事实算出的本变更触及模块文档欠账(无输出=无欠账或归属数据缺失)。
285
- 欠账处理:Wave 收尾时顺手同步对应模块卡(变更索引追加条目),不必为此停下。
325
+ 欠账处理:Wave 收尾时顺手同步对应模块卡,不必为此停下。变更索引类条目追加到卡同目录 \`<module>.changelog.md\` sidecar(无则创建;可先跑 \`sillyspec modules split-changelog\` 迁出历史段)——勿把历史条目堆回模块卡正文(卡是子代理的读取税)。
286
326
 
287
327
  ### 铁律
288
328
  - **不要询问用户确认频率**,默认 wave 模式;用户已明确口头指定时遵从其指定`,
@@ -310,14 +350,17 @@ tier: {REVIEW_TIER}({REVIEW_TIER_REASON})
310
350
  2. design.md 整体对照(最终实现拼起来是否仍符合设计意图,而非仅各 task 局部合规)
311
351
  3. 组装行为(全量测试/构建/启动通过——单 task 测试全绿 ≠ 组装正确)
312
352
 
353
+ **产物唯一化(省重复消耗)**:本步逐项对照结论**只落盘一份**——直接写进 review.json 的 \`checklist\` 数组(item=设计要点/FR/决策,note=实现状态 ✅/⚠️/❌ + 偏差说明 + commit 锚点),reviewerNotes 写汇总。**不要**另写独立的 design-check.md 长文(同一份 design×diff 二次消费;2026-08-22 实测该重复一遍 ≈8 分钟全量重读)。
354
+ **gate 重试修复**:review.json 落盘后若后续 gate 报 docHash 失配 / 路径错,跑 \`sillyspec register-stage-review --change <变更名> --stage execute --refresh-hash\` 一键重算修复——**不要重做审查**(重做=同一材料第三遍)。
355
+
313
356
  ### 操作
314
- 1. 读取 design.md(技术方案)
357
+ 1. 读取 design.md(技术方案)——按章节精准读(design-check 对照表可借 tasks 卡 §锚点定位),避免反复整读全文
315
358
  2. 逐一对照 design.md 中的设计要点与实际代码实现
316
359
  3. 检查接口签名、数据结构、模块划分是否一致
317
360
  4. 记录偏差项(偏差 ≠ 错误,可能是合理的实现调整)
318
361
 
319
362
  ### 输出
320
- 检查清单:每项设计要点的实现状态 ✅/⚠️/❌ + 偏差说明`,
363
+ review.json 已落盘(checklist=逐项核验表)+ checklist 摘要与偏差说明;无独立长文产物`,
321
364
  outputHint: '设计对照检查清单',
322
365
  optional: false
323
366
  },
@@ -489,9 +532,14 @@ function parseWavesFromPlan(planContent, registry = []) {
489
532
  const waves = []
490
533
  const regById = new Map(registry.map(t => [t.id, t]))
491
534
  let currentWave = null
535
+ // Wave 标题正则(坑 wave-heading-undercount,2026-08-21 实证:plan 6 个 Wave 只解析出 5 个,
536
+ // 末 Wave 任务静默落入「运行测试」验收步靠批量完成兜住)。空格可选("Wave6"/"Wave 6")、
537
+ // 编号后缀任意("## Wave 6(测试)")——解析侧宁可多收(字母后缀重复编号的硬拦仍在
538
+ // validatePlanForExecute),不可静默丢。
539
+ const WAVE_HEADING_RE = /^#+\s*Wave\s*(\d+)/i
492
540
 
493
541
  for (const line of String(planContent || '').split('\n')) {
494
- const waveMatch = line.match(/^#+\s*Wave\s+(\d+)/i)
542
+ const waveMatch = line.match(WAVE_HEADING_RE)
495
543
  if (waveMatch) {
496
544
  currentWave = { index: parseInt(waveMatch[1]), tasks: [] }
497
545
  waves.push(currentWave)
@@ -509,6 +557,20 @@ function parseWavesFromPlan(planContent, registry = []) {
509
557
  }
510
558
  }
511
559
 
560
+ // wave-like 漂移告警(坑 wave-heading-undercount 运行时防线):比解析正则更宽的「像 Wave
561
+ // 标题」形态(波次/W+数字等)若未被收容,说明该 Wave 段的任务会被静默漏进后续步骤——
562
+ // execute 启动即 warn(agent 当场修 plan),而非收尾才发现步骤表缺 Wave。
563
+ try {
564
+ const likeRe = /^#{1,6}\s*(?:wave|w|波次)\s*\d+/i
565
+ const likeHeadings = String(planContent || '').split('\n')
566
+ .map(l => l.replace(/\r$/, ''))
567
+ .filter(l => likeRe.test(l) && !WAVE_HEADING_RE.test(l))
568
+ if (likeHeadings.length > 0) {
569
+ console.warn(`⚠️ plan 存在 ${likeHeadings.length} 个「疑似 Wave 标题但未被识别」的行(其任务会漏进后续步骤):`)
570
+ for (const h of likeHeadings.slice(0, 5)) console.warn(` ${h.trim().slice(0, 60)}(须字面 "## Wave N" 格式)`)
571
+ }
572
+ } catch { /* 告警失败不阻断解析 */ }
573
+
512
574
  // 无显式 Wave 结构但注册表非空(light 级:任务全在 tasks.md、plan.md 只留策略)→
513
575
  // 合成单隐式 Wave 收容全部任务(与旧 light `## Tasks` 隐式收容语义对齐,单 Wave 串行执行)
514
576
  if (waves.length === 0 && registry.length > 0) {
@@ -772,6 +834,94 @@ ${prototypes.map(p => `- \`${path.join(protoRelDir, p)}\``).join('\n')}
772
834
  } catch {}
773
835
  }
774
836
 
837
+ // ── 模块卡分级(P0a):本 Wave per-task 最优卡表,细卡优先——治「根层大卡被每个子代理
838
+ // 整读」(multi-agent-platform backend.md 58KB 实测单卡一项 20 万+ tokens/全流程)。
839
+ // best-effort:解析失败/无 map 不注入(零回归),可经 `sillyspec modules resolve` 手查。
840
+ let moduleSection = ''
841
+ try {
842
+ if (changeDir) {
843
+ const specBase = path.dirname(path.dirname(changeDir))
844
+ // repo root 推导:默认 spec 布局(<repo>/.sillyspec/changes/<change>)从 specBase 反推,
845
+ // 平台模式(specRoot 异位)回退 process.cwd()——子项目目录前缀判定用,误差只降级匹配粒度
846
+ const repoRoot = path.basename(specBase) === '.sillyspec' ? path.dirname(specBase) : process.cwd()
847
+ const waveTaskIds = new Set(wave.tasks.map((t, ti) => `task-${String(t.index || (ti + 1)).padStart(2, '0')}`))
848
+ const { hasMaps, rows } = resolveChangeModuleCards({
849
+ cwd: repoRoot,
850
+ specBase,
851
+ changeName: path.basename(changeDir),
852
+ })
853
+ const hits = rows.filter(r => waveTaskIds.has(r.taskId) && r.moduleId)
854
+ if (hasMaps && hits.length > 0) {
855
+ const kb = n => (n > 0 ? `${(n / 1024).toFixed(1)}KB` : '?')
856
+ const cardLines = hits.map(r =>
857
+ `- ${r.taskId} → \`${r.cardPath}\`(${r.moduleId},${r.granularity === 'fine' ? '细卡' : '粗卡'} ${kb(r.cardBytes)}:${r.advice})`
858
+ ).join('\n')
859
+ moduleSection = `
860
+ ### 模块卡分级(本 Wave 子代理按表引用,勿按目录漫游 / 整读根层大卡)
861
+
862
+ ${cardLines}
863
+
864
+ 细粒度卡整卡读;粗粒度大卡只读「契约摘要/注意事项/定位」节(跳过变更索引/人工备注历史段)。为子代理写 prompt 时把对应卡路径直接写入。
865
+ `
866
+ }
867
+ }
868
+ } catch { /* 模块卡分级是 best-effort:失败不注入不阻断 */ }
869
+
870
+ // ── design.md 热区(P1a):Wave 前置只需「非目标/兼容策略」两节,CLI 提取直供——免每
871
+ // Wave 整读全文(28KB 级 design × 8 Wave 重复读)。无 design.md / 无对应节 → 不注入零回归。
872
+ let designHotzone = ''
873
+ let hasDesignHotzone = false
874
+ try {
875
+ if (changeDir) {
876
+ const designPath = path.join(changeDir, 'design.md')
877
+ if (existsSync(designPath)) {
878
+ const SECTION_CHAR_LIMIT = 2400
879
+ const dLines = readFileSync(designPath, 'utf8').replace(/\r\n/g, '\n').split('\n')
880
+ const sections = []
881
+ let cur = null
882
+ dLines.forEach((l, i) => {
883
+ const m = l.match(/^##\s+(.+)$/)
884
+ if (m) {
885
+ if (cur) cur.end = i
886
+ cur = { title: m[1].trim(), start: i, end: dLines.length }
887
+ sections.push(cur)
888
+ }
889
+ })
890
+ const grab = kwRe => {
891
+ const s = sections.find(s => kwRe.test(s.title))
892
+ if (!s) return null
893
+ const body = dLines.slice(s.start + 1, s.end).join('\n').trim()
894
+ if (!body) return null
895
+ return { title: s.title, body: body.length > SECTION_CHAR_LIMIT ? body.slice(0, SECTION_CHAR_LIMIT) + '\n…(超长截断,原文见 design.md)' : body }
896
+ }
897
+ const nonGoals = grab(/非目标/)
898
+ const compat = grab(/兼容/)
899
+ if (nonGoals || compat) {
900
+ hasDesignHotzone = true
901
+ const fmt = s => (s ? `#### ${s.title}\n${s.body}\n` : '')
902
+ const indexLines = sections.map(s => `- L${s.start + 1} ## ${s.title}`).join('\n')
903
+ designHotzone = `
904
+ ### design.md 热区(CLI 提取——Wave 前置只读这两节,勿整读全文)
905
+
906
+ ${fmt(nonGoals)}${fmt(compat)}
907
+ ### design.md 章节行号索引(需要其余章节时按行号精准读,Read offset/limit)
908
+
909
+ ${indexLines}
910
+ `
911
+ }
912
+ }
913
+ }
914
+ } catch { /* 热区提取 best-effort:失败不注入 */ }
915
+
916
+ // 子代理要点 4 / Wave 开始前第 1 条:模块表 / 热区存在时换成「按注入内容执行」版文案,
917
+ // 否则保留原文(零回归——无 map / 无 design 的项目 prompt 不变)
918
+ const moduleDocPoint = moduleSection
919
+ ? '4. **模块卡分级**:把上方「模块卡分级」表中该 task 命中的卡路径写进子代理 prompt(细卡整读;粗大卡只读「契约摘要/注意事项/定位」节)——勿让子代理按目录漫游或整读根层大卡'
920
+ : '4. 如存在模块文档(.sillyspec/docs/*/modules/),按需读取涉及模块的 <module>.md 参考接口约定和数据流'
921
+ const waveStartItem1 = hasDesignHotzone
922
+ ? '1. 「非目标」「兼容策略」两节已由 CLI 提取附在上方「design.md 热区」——**不要再整读 design.md**;需要其余章节时按热区下方行号索引精准读取(Read offset/limit)。确保子代理不超范围、不破坏旧逻辑'
923
+ : '1. 读取 design.md 的「非目标」与「兼容策略」章节(如存在),确保子代理不超范围、不破坏旧逻辑'
924
+
775
925
  // 构建任务摘要(不再内联完整蓝图,减少上下文污染)
776
926
  const taskSummary = wave.tasks.map((t, ti) => {
777
927
  const taskNum = String(t.index || (ti + 1)).padStart(2, '0')
@@ -946,7 +1096,7 @@ ${workdirLines}
946
1096
 
947
1097
  ${worktreeSection}${crossRepoCommitSection}${dispatchSection}
948
1098
  **SillyHub 派发互斥**:SillyHub 派发模式下按派发段执行(一 Wave 一 mission),不按 batch 分组;batch 分组指导仅适用于本地 Agent tool 派发。
949
-
1099
+ ${moduleSection}
950
1100
  ### 任务摘要(按需读取完整蓝图)
951
1101
  为每个任务启动子代理时,**只需告知任务目标和蓝图文件路径,让子代理按需读取**:
952
1102
 
@@ -956,7 +1106,7 @@ ${taskSummary}
956
1106
  1. 任务目标(简短描述)
957
1107
  2. 蓝图文件路径(让子代理自行读取详情)
958
1108
  3. 编码铁律:先读后写、TDD、不编造方法、只做蓝图里写的事、遵守边界处理规则、不超出 allowed_paths
959
- 4. 如存在模块文档(.sillyspec/docs/*/modules/),按需读取涉及模块的 <module>.md 参考接口约定和数据流
1109
+ 4. ${moduleDocPoint}
960
1110
  5. 任务含测试代码时,把下方「测试用例设计」整段复制进子代理 prompt,要求子代理按此设计测试用例
961
1111
  6. **增量落盘与中断接手指引**:每完成一个可见产出(代码/测试/文档),立即写盘并执行一次最小验证(如语法检查、单跑相关测试)。工作过程中如被 429/API 配额/会话中断,应在最终回复里输出「已完成清单」(含文件路径、测试命令、当前卡点),不要只输出结论——主代理会依据磁盘产物和该清单判断哪些部分已完成,哪些需接手补做,避免重做已落盘的工作
962
1112
  7. **任务边界铁律**:严格只实现本 task 的 \`allowed_paths\` 内文件;若 design.md/plan.md 明确指定了接口/回调/钩子接入位置,必须逐字遵守;不允许顺手实现其他 task 的内容(如 task-01 不要把 task-02 的接入也做了)。如发现必须改其他 task 文件才能继续,先回到主代理由主代理决定是否重分 Wave 或调整 plan,禁止子代理私自越界
@@ -964,8 +1114,9 @@ ${taskSummary}
964
1114
 
965
1115
  {{include: testcase-design}}
966
1116
 
1117
+ ${designHotzone}
967
1118
  ### Wave 开始前
968
- 1. 读取 design.md 的「非目标」与「兼容策略」章节(如存在),确保子代理不超范围、不破坏旧逻辑
1119
+ ${waveStartItem1}
969
1120
  2. 读取 plan.md 了解全局任务划分和依赖关系
970
1121
  3. 确认本 Wave 的输入/输出契约(前置 Wave 产出了什么,本 Wave 需要消费什么)
971
1122
  4. 检查前置 Wave 的产出是否完整(文件是否存在、测试是否通过)
@@ -992,6 +1143,7 @@ ${taskList}
992
1143
  - 最后一个 Wave 完成后做一次全量编译验证
993
1144
  - 用户明确要求编译时
994
1145
  4. 每个任务完成后:
1146
+ - **在 worktree 内 git add -A && git commit -m "<task-NN 摘要>"**(坑 subagent-uncommitted-newfile-apply3way,2026-08-22 实证:纯新增文件不 commit 时 apply 的 git apply --3way 报 "does not exist in index" 直接炸——未 commit 的新文件不在 base commit 也不在 index,patch 生成取不到。commit 后 base..HEAD diff 完整、apply 顺畅,review.json 的 head 也有真实锚点)
995
1147
  - **先写 review.json 再勾选 checkbox**(见下方 Task Review Gate)
996
1148
  - **既跑 lint check 也跑 formatter**:凡变更涉及的源码跑项目的 lint 检查 **和** 格式化(如 \`ruff format\` / \`prettier --write\`),不要只跑 check——只 check 不 format 会把格式问题留到 commit 时被 pre-commit hook 拦截(worktree 内二进制可能缺失,先 \`which <bin>\` 确认,缺则 \`uv tool install\` / \`uv sync\`)
997
1149
  - 记录改动文件和测试结果
@@ -1043,10 +1195,11 @@ task-XX 对应:{SPEC_ROOT}/.runtime/execute-runs/{EXECUTE_RUN_ID}/tasks/task-X
1043
1195
  - execute 末步「知识库审阅」会检查待确认条目并提示用户归类
1044
1196
 
1045
1197
  ### 完成后
1046
- 1. 为每个后端 router task,扫描变更文件提取 API 端点 artifact:
1047
- - 在变更文件中搜索所有 router 注册路径(@router.get/post/put/delete)
1048
- - 将端点清单写入 {SPEC_ROOT}/.runtime/contract-artifacts/<task-name>/endpoints.json
1049
- - 格式: { "task": "task-XX", "type": "backend_endpoints", "endpoints": [{ "method": "GET", "path": "/api/ppm/xxx" }] }
1198
+ 1. 为每个后端 router task 生成 API 端点 artifact——**用 CLI 命令,勿手扫装饰器手写(易漏 endpoint)**:
1199
+ - 逐 task 运行:sillyspec endpoints extract --change <change> --task <task-NN>(<change> = 当前变更名)
1200
+ - CLI 静态扫描变更文件(FastAPI @router.* / Express router.* / Spring @*Mapping)生成
1201
+ {SPEC_ROOT}/.runtime/contract-artifacts/<task-name>/endpoints.json
1202
+ - 格式: { "task": "task-XX", "type": "backend_endpoints", "endpoints": [{ "method": "GET", "path": "/api/ppm/xxx" }] }(CLI 已按此格式写好,verify 探针 5 直接消费)
1050
1203
  `
1051
1204
  }
1052
1205
 
@@ -18,14 +18,23 @@ export const definition = {
18
18
  3. 可以用 \`rg\` / \`ls\` / \`cat\` 调查已有结构和集成点
19
19
  4. 输出 2-3 个有价值方向、关键风险和下一步建议
20
20
  5. 如果用户要求保存结论,先明确保存位置,再写入对应文档
21
+ 6. 讨论 UI/交互方案且用户明确要求原型时,按下方「HTML 原型生成」执行
22
+
23
+ ### HTML 原型生成(仅用户明确要求时)
24
+ 探索保持只读姿态:不主动写原型文件;UI 讨论成形时可以提议("要不要出个原型看效果"),用户同意后再生成。生成要求与 brainstorm 阶段的原型对齐:
25
+ - 单文件 HTML(内联 CSS + JS),浏览器直接打开
26
+ - 高保真呈现讨论中的布局/组件/交互流程——用户看到的是"这个方案做出来长什么样",不是示意图
27
+ - 与项目现有界面风格一致:先读 scan 文档与现有前端代码,复用现有组件库/设计 token 的观感,不另起风格
28
+ - 保存到 \`{SPEC_ROOT}/explore/prototype-<名称>.html\`(目录不存在则先创建;用户指定了其他位置则从其指定)
29
+ - 原型是探索讨论工具、不是交付物,也不是实现代码——不要顺手把原型接进项目源码或构建;话题后续进入 brainstorm 时,把原型文件复制到 \`{SPEC_ROOT}/changes/<变更名>/\` 可直接复用为该变更的原型
21
30
 
22
31
  ### 输出
23
- 探索结论、选项对比、风险清单或 ASCII 图
32
+ 探索结论、选项对比、风险清单或 ASCII 图;如生成了原型,附原型文件路径
24
33
 
25
34
  ### 铁律
26
35
  - 不写实现代码
27
36
  - 不安装依赖
28
- - 不修改文件,除非用户明确要求保存探索结论
37
+ - 不修改文件,除非用户明确要求保存探索结论或生成原型
29
38
  - 不强行推进到 brainstorm/plan/execute`,
30
39
  outputHint: '探索结论',
31
40
  optional: false
@@ -448,7 +448,9 @@ export function validateBlueprintConsistency(changeDir) {
448
448
  if (ts.length > 1) {
449
449
  errors.push(
450
450
  `路径 ${p} 被 Wave ${w} 内 ${ts.length} 个 task 修改: ${ts.join(', ')} — 同 Wave 任务 execute 强制并行(execute.js:603),` +
451
- `子代理会互相覆盖该文件。解法:把它们拆到不同 Wave(串行)。`
451
+ `子代理会互相覆盖该文件。解法:把它们拆到不同 Wave(串行)。` +
452
+ `(注:quick 阶段对同文件并发只提示不阻断——那是轻量流程无并行子代理、最坏后果 git 可分离;` +
453
+ `此处硬拦是因并行覆盖不可自动恢复,两阶段宽严不同是设计使然)`
452
454
  )
453
455
  }
454
456
  }
@@ -473,6 +473,9 @@ ${taskList}
473
473
  \`\`\`
474
474
  你是一个专注的 TaskCard 生成器。你的任务是为指定 batch 生成全部 TaskCard。
475
475
 
476
+ ## ⚠️ 格式形态(第一眼必读,坑 taskcard-body-section-rework)
477
+ TaskCard 的契约字段全部在 **frontmatter**(首对 --- 包裹的 YAML 键值对:id/title/title_zh/allowed_paths/goal/...)——**不是 body 章节**(## 标题下的段落)。goal 是 frontmatter 的 \`goal: >\` 多行标量、implementation/acceptance/verify/constraints 是 frontmatter 的列表项;写成 body 章节(\`## goal\` / \`## Goal\` 等)会三组校验全挂返工。先跑骨架命令再 Edit 填充,骨架即正确形态。
478
+
476
479
  ## 输入
477
480
  - 变更目录:${changeDir}
478
481
  - 当前时间:<now-datetime>(frontmatter 的 created_at 使用此值)
@@ -132,10 +132,11 @@ sillyspec run quick --done --change <id> --req "…" --cause "…" --solution "
132
132
  7. 对比本次修改的文件(\`git diff --name-only HEAD\`)与模块映射
133
133
  8. 如果命中模块 → 直接同步模块文档:
134
134
  - 读取对应的 \`{SPEC_ROOT}/docs/<project>/modules/<module>.md\`(如不存在则新建)
135
- - 根据本次改动内容更新模块文档(正文描述当前状态,底部变更索引追加本步骤预注入的 ql-ID)
135
+ - 根据本次改动内容更新模块文档(正文描述当前状态;**勿把历史条目堆进正文——卡是子代理的读取税**)
136
+ - 变更索引条目追加到卡同目录 \`<module>.changelog.md\` sidecar(无则创建;历史段可跑 \`sillyspec modules split-changelog\` 迁出)
136
137
  - 变更索引格式:\`- <quicklog-id> | <一句话描述>\`
137
- - 写入模块文档
138
- - 使用 \`git add -- <module-doc>\` 暂存更新的模块文件
138
+ - 写入模块文档与 sidecar
139
+ - 使用 \`git add -- <module-doc>\` 暂存更新的模块文件(sidecar 一并暂存)
139
140
  9. 未命中任何模块 → 跳过,不做额外操作
140
141
 
141
142
  ### 输出
@@ -172,11 +172,11 @@ export const definition = {
172
172
  - Gradle:\`./gradlew :<模块>:test\`
173
173
  - npm/pnpm:\`pnpm test --filter=<包名>\` 或 \`npm test -- --testPathPattern=<相关文件>\`
174
174
  - Python:\`pytest <变更模块路径>/\`
175
- 3. 如果 local.yaml 有 lint 命令,运行 lint 检查
175
+ 3. 如果 local.yaml 有 lint 命令,运行 lint 检查并修复报出的问题(--done 时 CLI 会亲自实测 commands.lint 对账,实测失败会明示)
176
176
  4. 搜索技术债务:grep TODO/FIXME/HACK/XXX(仅限变更文件)
177
177
 
178
178
  ### 注意
179
- - **CLI 对账机制**:verify 阶段最终 --done 时,CLI 会亲自执行 local.yaml 的 commands.test(同步,耗时可能较长);实测失败会直接阻断 verify 完成,谎报测试结果没有意义
179
+ - **CLI 对账机制**:verify 阶段最终 --done 时,CLI 会亲自执行 local.yaml 的 commands.test(同步,耗时可能较长);实测失败会直接阻断 verify 完成,谎报测试结果没有意义。commands.lint 同样会被 CLI 实测对账(advisory)
180
180
  - 冒烟测试非必需:全量/模块实测结果以 CLI 对账为准,本步跑的结果仅供你提前发现问题并写入验证报告
181
181
 
182
182
  ### 输出
@@ -207,6 +207,13 @@ export const definition = {
207
207
 
208
208
  **Runtime Evidence 行结构**(integration/deployment-critical 必填;按实际触碰的运行时组件写,未涉及的行写「不涉及」勿堆关键词):长驻进程启动命令 / 触碰的服务端点 / 触发核心路径的请求(附关键响应)/ 进程日志关键片段(证明走了新路径)/ 生命周期终态断言(初始态→运行态→终态)/ 失败模式排除(逐条说明为何未触发)。
209
209
 
210
+ ### 🧹 服务进程登记与自动回收(真实启动验证必读,坑 verify-service-process-leak)
211
+ 「真实启动一次」起的长驻服务(uvicorn/node server 等)**必须登记 PID,CLI 在 verify 收尾自动回收**——不登记的进程会挂死机器(实测 uvicorn 漏挂一天多):
212
+ 1. 后台启动服务并取 PID(PowerShell:\`Start-Process -PassThru\` 取 \`.Id\`;bash:\`cmd & echo $!\`)
213
+ 2. 每行一个 PID 追加到 \`\${SPEC_ROOT}/.runtime/verify-services.pids\`(\`Add-Content\` / \`echo >>\`)
214
+ 3. verify \`--done\` 时 CLI 读该文件逐个 kill 并清空——你在 verify-result.md 的「生命周期终态断言」里写「PID 已登记,CLI 收尾回收」即可
215
+ 登记是硬要求:没有 PID 登记的「真实启动」证据在 deployment-critical 门控下视为不完整(进程无法证明被回收)。
216
+
210
217
  ### 输出
211
218
  verify-result.md 路径 + 验证报告摘要 + 下一步命令
212
219
 
package/src/sync.js CHANGED
@@ -13,6 +13,7 @@ import { writeAtomicSync } from './fs-atomic.js';
13
13
  import { join } from 'path';
14
14
  import { resolvePlatformSpecDir } from './progress.js';
15
15
  import { safeGit } from './git-helper.js';
16
+ import { openDatabase } from './db-engine.js';
16
17
  import { PLATFORM_MANAGED_FILENAME } from './run/shared.js';
17
18
  import { syncSpecTree } from './spec-sync.js';
18
19
 
@@ -267,6 +268,9 @@ async function fetchJsonWithStatus(url, options = {}) {
267
268
  export class SyncManager {
268
269
  constructor(cwd) {
269
270
  this.cwd = cwd;
271
+ // 归档尾声静默化旗标(坑 post-archive-sync-noise):sync() 探测到已归档时置位,
272
+ // 链内 syncDocuments 的「变更不存在」降 debug(每次 sync 调用重置)
273
+ this._suppressDocsMissingWarn = false;
270
274
  }
271
275
 
272
276
  /**
@@ -411,12 +415,24 @@ export class SyncManager {
411
415
  return { synced: 0, errors: ['未指定变更名称'] };
412
416
  }
413
417
 
414
- // 检查变更目录是否存在(warn 不拦:归档后目录已移走但 DB 仍有最终状态需推平台,
415
- // serializeForSync 从 DB 读不依赖文件系统目录;目录存在时 warn 也无害,仅辅助排查)
418
+ // 检查变更目录是否存在(归档后目录已移走但 DB 仍有最终状态需推平台,serializeForSync 从
419
+ // DB 读不依赖文件系统目录)。归档终态探测(坑 post-archive-sync-noise,2026-08-21 实证:
420
+ // 归档尾声连打「目录不存在/变更不存在」warn,观感像出错):确认已归档(changes/archive/
421
+ // 有实体 或 DB status='archived')时降为一行 info 措辞——正常时序不是告警。
416
422
  const changeDir = join(this.cwd, CHANGES_DIR, changeName);
423
+ let archivedQuietly = false;
417
424
  if (!existsSync(changeDir)) {
418
- console.warn(`[sync] 变更目录不存在(可能是已归档,继续从 DB 同步最终状态): ${changeName}`);
425
+ archivedQuietly = existsSync(join(this.cwd, CHANGES_DIR, 'archive', changeName))
426
+ || this._isChangeArchivedInDb(changeName);
427
+ if (archivedQuietly) {
428
+ console.log(`[sync] 变更已归档(目录在 archive/),继续从 DB 推送最终状态: ${changeName}`);
429
+ } else {
430
+ console.warn(`[sync] 变更目录不存在(可能是已归档,继续从 DB 同步最终状态): ${changeName}`);
431
+ }
419
432
  }
433
+ // 透传给本次 sync 链内的 syncDocuments(best-effort 四件套直推:归档后目录已移走,
434
+ // 其「变更不存在」warn 属正常时序,静默化)
435
+ this._suppressDocsMissingWarn = archivedQuietly;
420
436
 
421
437
  const MAX_PUSH_ATTEMPTS = 2;
422
438
  for (let attempt = 1; attempt <= MAX_PUSH_ATTEMPTS; attempt++) {
@@ -617,6 +633,12 @@ export class SyncManager {
617
633
  // 返回值 → 文档在平台模式下静默永不同步。
618
634
  const changeDir = join(safePlatformSpecDir(this.cwd) || join(this.cwd, '.sillyspec'), 'changes', changeName);
619
635
  if (!existsSync(changeDir)) {
636
+ // 归档尾声静默化(坑 post-archive-sync-noise):sync() 链内调用(_suppressDocsMissingWarn
637
+ // 置位,目录已移 archive/ 的正常时序)降为 debug;独立调用(手动 sync-docs)保留 warn
638
+ if (this._suppressDocsMissingWarn) {
639
+ debugLog(`[sync] 四件套目录不存在(变更已归档,跳过文档直推): ${changeName}`);
640
+ return { synced: 0, errors: [] };
641
+ }
620
642
  console.warn(`[sync] 变更不存在: ${changeName}`);
621
643
  return { synced: 0, errors: [`变更不存在: ${changeName}`] };
622
644
  }
@@ -734,6 +756,25 @@ export class SyncManager {
734
756
  return config.platform || null;
735
757
  }
736
758
 
759
+ /**
760
+ * DB 侧归档态探测(坑 post-archive-sync-noise):changes 表 status='archived' 即真。
761
+ * 只读直查(node:sqlite,不经 ProgressManager——sync 是短进程,惰性 import 无收益且
762
+ * 本调用点在同步代码段);DB 不存在/无行/读失败 → false(保守退回普通 warn 措辞)。
763
+ * @param {string} changeName
764
+ * @returns {boolean}
765
+ */
766
+ _isChangeArchivedInDb(changeName) {
767
+ try {
768
+ const dbPath = join(safePlatformSpecDir(this.cwd) || join(this.cwd, '.sillyspec'), '.runtime', 'sillyspec.db');
769
+ if (!existsSync(dbPath)) return false;
770
+ const db = openDatabase(dbPath, { readOnly: true });
771
+ try {
772
+ const row = db.prepare("SELECT status FROM changes WHERE name = ?").get(changeName);
773
+ return row?.status === 'archived';
774
+ } finally { try { db.close() } catch {} }
775
+ } catch { return false }
776
+ }
777
+
737
778
  /**
738
779
  * 写冲突文件 .runtime/sync-conflict-<change>.json(task-12 / D-002 / D-008 / D-010 / FR-05)。
739
780
  * push 409 与 pull 本地脏度双向冲突命中时调用,强制提示用户走 platform resolve 三选一。
@@ -742,6 +783,38 @@ export class SyncManager {
742
783
  * @param {{base_ts?: string|null, local_modified_ts?: string|null, platform_last_pushed_at?: string|null, platform_progress?: object|null}} info
743
784
  * @returns {string|null} 冲突文件路径(specDir 不可达返回 null)
744
785
  */
786
+ /**
787
+ * 六表内容比对(坑 pull-deploy-noise-conflict 自愈用):本地 serializeForSync 快照与平台
788
+ * 拉回的 JSON 逐表深度比对,忽略时间戳/同步元数据列(last_active/last_synced_platform_ts/
789
+ * last_local_modified_ts/started_at/completed_at/pushed_at/checked_at/completed_at 等——部署
790
+ * 噪声重推会刷新这些列但内容不变)。任一实质差异 → false。
791
+ * @param {object} local - serializeForSync 输出
792
+ * @param {object} platform - 平台 GET progress JSON(serializeForSync 同构)
793
+ * @returns {boolean}
794
+ */
795
+ _progressContentEquals(local, platform) {
796
+ try {
797
+ const IGNORE_KEYS = new Set([
798
+ 'last_active', 'last_synced_platform_ts', 'last_local_modified_ts',
799
+ 'started_at', 'completed_at', 'pushed_at', 'last_pushed_at', 'created_at',
800
+ 'deps_checked_at', 'checked_at', 'waited_at', 'completedat',
801
+ ]);
802
+ const strip = (v) => {
803
+ if (Array.isArray(v)) return v.map(strip);
804
+ if (v && typeof v === 'object') {
805
+ const out = {};
806
+ for (const [k, val] of Object.entries(v).sort()) {
807
+ if (IGNORE_KEYS.has(k)) continue;
808
+ out[k] = strip(val);
809
+ }
810
+ return out;
811
+ }
812
+ return v;
813
+ };
814
+ return JSON.stringify(strip(local)) === JSON.stringify(strip(platform));
815
+ } catch { return false }
816
+ }
817
+
745
818
  _writeConflictFile(changeName, info = {}) {
746
819
  const specDir = safePlatformSpecDir(this.cwd);
747
820
  if (!specDir) return null;
@@ -924,6 +997,28 @@ export class SyncManager {
924
997
  }
925
998
  } catch { /* 重读失败维持原判(fail-closed 到真冲突分支) */ }
926
999
  }
1000
+ // ── 内容一致自愈(坑 pull-deploy-noise-conflict,2026-08-22 实证:并行会话部署扰动——
1001
+ // 他机 push 了内容相同的进度(如重启/重部署触发重推),本地脏 + 平台 ts 更新 → 拉冲突落
1002
+ // sync-conflict 文件需人工 resolve,但内容其实无分歧。比对平台 JSON 与本地 serializeForSync
1003
+ // 的六表内容(忽略时间戳列),一致 → 跳过 import(本地为准)+ base_ts 推进到平台 ts——
1004
+ // 与 resolve --keep-local 同语义,自动闭环。内容不同 → 真冲突,维持原判。
1005
+ if (localDirty && platformNewer && platformProgress) {
1006
+ try {
1007
+ const { ProgressManager } = await import('./progress.js');
1008
+ const pm = new ProgressManager({ specDir: safePlatformSpecDir(this.cwd) });
1009
+ const localSnapshot = pm.serializeForSync(this.cwd, changeName);
1010
+ if (localSnapshot && this._progressContentEquals(localSnapshot, platformProgress)) {
1011
+ try {
1012
+ const db = pm._ensureDB(this.cwd).getDb();
1013
+ db.prepare(
1014
+ 'UPDATE changes SET last_synced_platform_ts = MAX(?, COALESCE(last_synced_platform_ts, ?)) WHERE name = ?'
1015
+ ).run(platformPushedAt, platformPushedAt, changeName);
1016
+ } catch {}
1017
+ debugLog(`[sync] pull 内容一致自愈: 平台 ts ${platformPushedAt} 为部署噪声重推(六表内容与本地一致),base_ts 已推进,不落冲突文件`);
1018
+ return { ok: true, imported: false, conflict: false, reason: '平台重推内容与本地一致(部署噪声),base_ts 已推进' };
1019
+ }
1020
+ } catch { /* 比对失败维持原判(fail-closed 到真冲突分支) */ }
1021
+ }
927
1022
  if (localDirty && platformNewer) {
928
1023
  console.warn('');
929
1024
  console.warn('⚠️⚠️⚠️════════════════════════════════════════════════════');