sillyspec 3.26.14 → 3.26.15

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 (50) hide show
  1. package/.claude/skills/sillyspec-archive/SKILL.md +2 -0
  2. package/.claude/skills/sillyspec-brainstorm/SKILL.md +1 -1
  3. package/.claude/skills/sillyspec-commit/SKILL.md +18 -60
  4. package/.claude/skills/sillyspec-continue/SKILL.md +2 -0
  5. package/.claude/skills/sillyspec-execute/SKILL.md +10 -4
  6. package/.claude/skills/sillyspec-plan/SKILL.md +10 -2
  7. package/.claude/skills/sillyspec-quick/SKILL.md +2 -2
  8. package/.claude/skills/sillyspec-resume/SKILL.md +2 -0
  9. package/.claude/skills/sillyspec-verify/SKILL.md +2 -0
  10. package/.claude/skills/sillyspec-workspace/SKILL.md +7 -1
  11. package/package.json +1 -1
  12. package/src/commit-suggest.js +202 -0
  13. package/src/config-cat.js +88 -0
  14. package/src/docs-check.js +48 -12
  15. package/src/doctor-diagnostics.js +49 -0
  16. package/src/hooks/worktree-guard.js +20 -1
  17. package/src/index.js +508 -6
  18. package/src/init.js +1 -1
  19. package/src/local-register.js +98 -0
  20. package/src/module-impact.js +146 -0
  21. package/src/progress/change-registry.js +43 -4
  22. package/src/progress/stage-machine.js +10 -5
  23. package/src/progress.js +13 -1
  24. package/src/quicklog.js +106 -1
  25. package/src/run/command.js +95 -1
  26. package/src/run/complete-handlers.js +56 -3
  27. package/src/run/complete.js +4 -7
  28. package/src/run/gates.js +62 -4
  29. package/src/run/multi-repo-context.js +3 -1
  30. package/src/run/next.js +128 -0
  31. package/src/run/prompt.js +20 -2
  32. package/src/run/shared.js +2 -2
  33. package/src/scan-postcheck.js +142 -2
  34. package/src/stage-contract-spec.js +1 -1
  35. package/src/stage-review.js +15 -6
  36. package/src/stages/archive.js +1 -1
  37. package/src/stages/brainstorm.js +1 -0
  38. package/src/stages/execute.js +105 -26
  39. package/src/stages/knowledge.js +3 -2
  40. package/src/stages/quick.js +14 -6
  41. package/src/stages/scan.js +1 -0
  42. package/src/stages/verify.js +7 -49
  43. package/src/task-review.js +218 -4
  44. package/src/verify-probes.js +264 -0
  45. package/src/workspace.js +125 -0
  46. package/src/worktree-apply.js +15 -1
  47. package/src/worktree.js +58 -8
  48. package/templates/claude-instruction.md +1 -1
  49. package/templates/prompts/taskcard-rules.md +1 -1
  50. package/templates/prompts/verify-probes.md +13 -47
@@ -36,7 +36,7 @@ export const definition = {
36
36
  5. module-impact.md 第一行标题必须用中文:# 模块影响分析(Module Impact)— <变更简述>
37
37
 
38
38
  ### 降级(module-impact.md 不存在)
39
- 若缺失(如旧变更未经新 plan 流程,或 small 变更从未生成),按原生成逻辑补一份:读 .sillyspec/workflows/archive-impact.yaml 的 impact-analyzer 角色规则 + git diff + _module-map.yaml,生成模块影响矩阵(模块 × 影响类型 × 相关文件 × 更新摘要 × needs_review,未匹配文件归入 unmapped 表)落盘 module-impact.md。
39
+ 若缺失(如旧变更未经新 plan 流程,或 small 变更从未生成):先跑 \`sillyspec module-impact --change <change-name>\`——CLI 按 _module-map.yaml paths 前缀匹配预填「模块影响矩阵」骨架(文件×模块归属 + 未匹配文件清单,机械部分全代算),你只需逐行填「影响类型」(逻辑变更/数据结构变更/接口变更/调用关系变更/配置变更/新增)与 needs_review 标记,补首行中文标题 \`# 模块影响分析(Module Impact)— <变更简述>\`。骨架无从生成(无 module-map / 无 diff)时才按 archive-impact.yaml 的 impact-analyzer 角色规则全手写。
40
40
 
41
41
  确认完成后,下一步 sync-module-docs 会读 module-impact.md 更新模块卡片文档。`,
42
42
  outputHint: 'module-impact.md 终审结果 + 修正摘要',
@@ -45,6 +45,7 @@ export const definition = {
45
45
  7. 检查全局模板:\`ls ~/.sillyspec/templates/\`
46
46
  - 有匹配模板 → 询问是否基于模板
47
47
  - 无相关内容 → 跳过,不输出
48
+ 8. 查询知识库:\`cat {SPEC_ROOT}/knowledge/INDEX.md 2>/dev/null\`(无索引则跳过)——按需求关键词命中条目时读取对应知识文件;known-issues/已知坑要在方案期就规避,不要等 execute 的命中报告才发现
48
49
 
49
50
  ### 模块匹配方法
50
51
  读取 _module-map.yaml 后,根据用户描述的需求关键词,匹配相关模块:
@@ -214,6 +214,7 @@ const fixedPrefix = [
214
214
 
215
215
  ### 符号影响面扩展检查
216
216
  11. **符号影响面扫描**(Critical — execute 前必做):
217
+ - **报告骨架勿手写**:先跑 \`sillyspec symbol-impact --change <change-name>\`——CLI 从 tasks.md 生成逐 task \`<!--TODO-->\` 骨架(gate 拦截时也会自动落一份);把每行占位替换为真实结论(**未替换的 TODO 占位会被 gate 拒绝**,骨架不能直接过门)
217
218
  - 读取所有 tasks/task-NN.md,提取每个任务涉及的修改文件
218
219
  - 对每个修改文件,检查是否涉及以下变更类型:
219
220
  - class 构造函数参数变更(新增/删除/修改参数)
@@ -221,7 +222,7 @@ const fixedPrefix = [
221
222
  - DTO / 类型定义变更
222
223
  - API client 方法签名变更
223
224
  - 函数/方法签名变更(参数增删改)
224
- - 如果涉及上述变更类型,执行调用点搜索:
225
+ - 如果涉及上述变更类型,执行调用点搜索(用于补骨架外的调用点证据,不是生成报告的方式):
225
226
  \`\`\`bash
226
227
  rg "new ClassName\(" src/
227
228
  rg "ClassName\(" src/
@@ -230,7 +231,7 @@ const fixedPrefix = [
230
231
  \`\`\`
231
232
  - 将搜索到的调用点与 plan.md 和 tasks/task-NN.md 的 allowed_paths 对比
232
233
  - **发现调用点不在任何 task 的 allowed_paths 中 → 直接阻断 execute**
233
- - **报告落盘(CLI 硬校验)**:把检查结论写入 \`{SPEC_ROOT}/changes/<change-name>/symbol-impact.md\`,每个 task 一行结论(task-XX: 变更类型 + 受影响调用点 + 是否在范围内;无签名级变更也要显式写「无签名级变更」)。\`--done\` 时 CLI 校验该文件存在且覆盖 plan.md 全部 task,缺失或不覆盖会阻断完成
234
+ - **报告落盘(CLI 硬校验)**:结论写入 \`{SPEC_ROOT}/changes/<change-name>/symbol-impact.md\`,每个 task 一行结论(task-XX: 变更类型 + 受影响调用点 + 是否在范围内;无签名级变更也要显式写「无签名级变更」)。\`--done\` 时 CLI 校验该文件存在且覆盖 plan.md 全部 task,缺失或不覆盖会阻断完成
234
235
  - 如果调用点不在范围内但任务明确写了"不改原因",记录但不阻断
235
236
 
236
237
  ### 输出
@@ -558,19 +559,20 @@ function resolveTaskRepo(task, changeDir) {
558
559
  }
559
560
 
560
561
  /**
561
- * 把 base_commit 锡点写入 task 卡 frontmatter(W3 task-08,D-010)。
562
- * 派发跨仓 task 前 CLI 实时 git rev-parse HEAD 落盘——锁定子代理起改的 base 锚点,
563
- * 防止同 Wave 多 task 改同一跨仓仓时 HEAD 推进致 diff 范围漂移(design §5.3 约束① / R-01)。
562
+ * 把 commit 锡点(base_commit/head_commit)写入 task 卡 frontmatter(W3 task-08,D-010;
563
+ * head 侧 2026-08-21 审计项③ 补对称)。
564
564
  *
565
- * 策略:幂等写——frontmatter 有 base_commit 行则就地替换,无则紧跟 repo 行后插入(
566
- * 无 frontmatter 时整段创建)。仅改 frontmatter 不动正文。已存在相同值不重复写。
565
+ * 策略:幂等写——frontmatter 有该字段行则就地替换,无则按 D-010 协议顺序插入:
566
+ * head_commit 跟在 base_commit 行后;base_commit 跟在 repo 行后;均无则插 frontmatter
567
+ * 首部。仅改 frontmatter 不动正文。已存在相同值不重复写。
567
568
  *
568
569
  * @param {string} taskFilePath - task-NN.md 绝对路径
569
- * @param {string} baseCommit - 跨仓仓 HEAD sha
570
+ * @param {'base_commit'|'head_commit'} field - 锚点字段名
571
+ * @param {string} commit - commit sha
570
572
  * @returns {boolean} 是否实际写入(false = 文件不存在 / 写失败 / 值未变)
571
573
  */
572
- function writeBaseCommitToTaskCard(taskFilePath, baseCommit) {
573
- if (!taskFilePath || !baseCommit) return false
574
+ export function writeCommitAnchorToTaskCard(taskFilePath, field, commit) {
575
+ if (!taskFilePath || !commit || (field !== 'base_commit' && field !== 'head_commit')) return false
574
576
  if (!existsSync(taskFilePath)) return false
575
577
  let content
576
578
  try {
@@ -579,27 +581,28 @@ function writeBaseCommitToTaskCard(taskFilePath, baseCommit) {
579
581
  return false
580
582
  }
581
583
  // CRLF 归一(坑 base-commit-crlf-frontmatter,plan-postcheck.js 同款):跨仓 task 卡被
582
- // 编辑器/子代理写成 CRLF 后 `^---\n` 匹配失败 → base_commit 锚点静默不落盘,而 Wave
584
+ // 编辑器/子代理写成 CRLF 后 `^---\n` 匹配失败 → 锚点静默不落盘,而 Wave
583
585
  // prompt 仍声称「CLI 已落盘」。归一后按 LF 写回(taskcard 生成侧本就强制 LF)。
584
586
  content = content.replace(/\r\n?/g, '\n')
585
587
  const fmMatch = content.match(/^---\n([\s\S]*?)\n---/)
586
588
  if (!fmMatch) return false
587
589
  const fm = fmMatch[1]
588
- const baseLineRe = /^base_commit:\s*.*$/m
590
+ const lineRe = new RegExp(`^${field}:\\s.*$`, 'm')
589
591
  let newFm
590
- if (baseLineRe.test(fm)) {
591
- const existing = (fm.match(baseLineRe) || [''])[0]
592
- if (existing === `base_commit: ${baseCommit}`) return false // 值未变,幂等跳过
593
- newFm = fm.replace(baseLineRe, `base_commit: ${baseCommit}`)
592
+ if (lineRe.test(fm)) {
593
+ const existing = (fm.match(lineRe) || [''])[0]
594
+ if (existing === `${field}: ${commit}`) return false // 值未变,幂等跳过
595
+ newFm = fm.replace(lineRe, `${field}: ${commit}`)
594
596
  } else {
595
- // 紧跟 repo 行后插入(与 D-010 协议同源顺序 repo→base_commit→head_commit)
596
- const repoLineRe = /^repo:\s*.*$/m
597
- const anchor = `base_commit: ${baseCommit}\n`
598
- if (repoLineRe.test(fm)) {
599
- newFm = fm.replace(repoLineRe, m => m + '\n' + anchor.trimEnd())
597
+ // 插入锚点(D-010 协议顺序 repo→base_commit→head_commit):head 跟 base 后,
598
+ // base 跟 repo 后,都无则 frontmatter 首字段
599
+ const anchor = `${field}: ${commit}`
600
+ const prevField = field === 'head_commit' ? 'base_commit' : 'repo'
601
+ const prevRe = new RegExp(`^${prevField}:\\s.*$`, 'm')
602
+ if (prevRe.test(fm)) {
603
+ newFm = fm.replace(prevRe, m => m + '\n' + anchor)
600
604
  } else {
601
- // 无 repo 行:插到 frontmatter 首部(frontmatter 首字段)
602
- newFm = anchor.trimEnd() + '\n' + fm
605
+ newFm = anchor + '\n' + fm
603
606
  }
604
607
  }
605
608
  const newContent = content.replace(/^---\n[\s\S]*?\n---/, `---\n${newFm}\n---`)
@@ -612,6 +615,75 @@ function writeBaseCommitToTaskCard(taskFilePath, baseCommit) {
612
615
  }
613
616
  }
614
617
 
618
+ function writeBaseCommitToTaskCard(taskFilePath, baseCommit) {
619
+ return writeCommitAnchorToTaskCard(taskFilePath, 'base_commit', baseCommit)
620
+ }
621
+
622
+ /**
623
+ * 跨仓 task head_commit 锡点自动落盘(2026-08-21 agent-手工产出审计项③,D-010 补对称)。
624
+ *
625
+ * base_commit 已在派发时 CLI 落盘,head_commit 此前靠主 agent 按 Wave prompt 指引手跑
626
+ * `rev-parse` 手写(漏抄/抄错直接炸 Task Review Gate 真实性校验)。本函数在 execute
627
+ * --done 时机补齐另一半:扫描 task 卡,跨仓 task(repo≠main)缺 head_commit 的,实时
628
+ * `git -C <跨仓仓根> rev-parse HEAD` 幂等写入(**已存在不覆盖**——agent 手写的精确锚点优先)。
629
+ *
630
+ * best-effort:MultiRepoContext 构造失败 / git 不可达 → 跳过该 task 记 reason,绝不抛。
631
+ *
632
+ * @param {{ changeName: string, cwd: string, specBase?: string, platformOpts?: object }} opts
633
+ * @returns {Promise<{ stamped: number, stampedTasks: string[], skipped: number, reasons: string[] }>}
634
+ */
635
+ export async function stampCrossRepoHeadCommits({ changeName, cwd, specBase, platformOpts = {} }) {
636
+ const base = specBase || platformOpts.specRoot || path.join(cwd, '.sillyspec')
637
+ const reasons = []
638
+ const stampedTasks = []
639
+ if (!changeName) {
640
+ return { stamped: 0, stampedTasks, skipped: 0, reasons: ['无 changeName'] }
641
+ }
642
+ const tasksDir = path.join(base, 'changes', changeName, 'tasks')
643
+ if (!existsSync(tasksDir)) {
644
+ return { stamped: 0, stampedTasks, skipped: 0, reasons: ['无 tasks/ 目录(单 task 变更,无跨仓锡点)'] }
645
+ }
646
+ let ctx = null
647
+ try {
648
+ const { getOrCreateMultiRepoContext } = await import('../run/shared.js')
649
+ ctx = await getOrCreateMultiRepoContext({ cwd, changeName, platformOpts })
650
+ } catch (e) {
651
+ return { stamped: 0, stampedTasks, skipped: 0, reasons: [`MultiRepoContext 构造失败: ${e.message}`] }
652
+ }
653
+ let stamped = 0
654
+ let skipped = 0
655
+ for (const f of readdirSync(tasksDir).filter(n => /^task-\d+\.md$/.test(n)).sort()) {
656
+ const taskFile = path.join(tasksDir, f)
657
+ let content
658
+ try {
659
+ content = readFileSync(taskFile, 'utf8')
660
+ } catch {
661
+ skipped++
662
+ continue
663
+ }
664
+ if (/^head_commit:/m.test(content)) continue // 已有(手写或前次落盘)不覆盖
665
+ const repoKey = parseRepo(content)
666
+ if (!repoKey || repoKey === 'main') continue // 主仓 task 锚 meta,无 head_commit 字段
667
+ const entry = ctx && ctx.resolve ? ctx.resolve(repoKey) : null
668
+ if (!entry || !entry.gitDir) {
669
+ skipped++
670
+ reasons.push(`${f}: repo "${repoKey}" 无法解析 gitDir(local.yaml repos 未注册?)`)
671
+ continue
672
+ }
673
+ const head = gitQuiet(entry.gitDir, ['rev-parse', 'HEAD'])
674
+ if (!head) {
675
+ skipped++
676
+ reasons.push(`${f}: git rev-parse HEAD 失败(${entry.gitDir})`)
677
+ continue
678
+ }
679
+ if (writeCommitAnchorToTaskCard(taskFile, 'head_commit', head)) {
680
+ stamped++
681
+ stampedTasks.push(f.replace(/\.md$/, ''))
682
+ }
683
+ }
684
+ return { stamped, stampedTasks, skipped, reasons }
685
+ }
686
+
615
687
  /**
616
688
  * 为 Wave 生成 prompt(强制子代理执行)
617
689
  *
@@ -768,9 +840,9 @@ ${crossLines}
768
840
  **派发跨仓 task 前(base 锡点,CLI 已在 prompt 构造时落盘):**
769
841
  - 跨仓 task 卡 frontmatter 的 \`base_commit\` 已由 CLI 实时 \`git -C <跨仓仓根> rev-parse HEAD\` 锁定(base 锡点,约束①)。子代理在此 HEAD 上改+commit,不受同 Wave 其他 task 推进 HEAD 影响。
770
842
 
771
- **回收跨仓 task review 前(head 锡点,你必须在子代理完成后落盘):**
772
- - 跨仓 task 子代理完成 commit 后、写 review.json / 勾选 checkbox **之前**,你必须运行 \`git -C <跨仓仓根> rev-parse HEAD\` 把结果写入该 task 卡 frontmatter \`head_commit:\` 字段(与 \`base_commit:\` 同源 frontmatter 锡点写入)。
773
- - review.json 的 \`base\` 取 task 卡 \`base_commit\`、\`head\` 取 task 卡 \`head_commit\`(**非瞬时 HEAD**),锁 diff 范围不漂移。
843
+ **回收跨仓 task(head 锡点,CLI 自动落盘,勿手写):**
844
+ - 子代理完成 commit 后,正常写 review.json(verdict/notes)并勾选 checkbox 即可。execute \`--done\` 时 CLI 自动 \`git -C <跨仓仓根> rev-parse HEAD\` 写入该 task 卡 \`head_commit:\`(幂等,已存在不覆盖——你若手写了精确锚点则以你的为准)。
845
+ - review.json 的 mechanics 字段(\`base\`/\`head\`/\`changedFiles\`)无需手算:\`base\` 取 task 卡 \`base_commit\`、\`head\` 取 task 卡 \`head_commit\`,写完跑 \`sillyspec backfill-reviews --change <变更名> --adopt\` 一键代填(verdict 保留)。
774
846
 
775
847
  **跨仓 task 子代理 prompt 必须注入:**
776
848
  > 该 task 改的是 \`<repo>\` 仓,workdir=\`<跨仓仓根>\`。**直接在该仓主干工作区改+commit(git add + git commit 到该仓主干),不经主仓 worktree、不建分支。** commit 到该仓主干即落盘,apply 阶段对跨仓 task 为 no-op(design §5.4 G1)。
@@ -963,6 +1035,13 @@ task-XX 对应:{SPEC_ROOT}/.runtime/execute-runs/{EXECUTE_RUN_ID}/tasks/task-X
963
1035
  - 无 _module-map.yaml 时跳过模块匹配,仅按文件清单更新 unmapped 部分
964
1036
  这是可选更新(不阻断 execute 完成),但保持 module-impact 与实际变更一致利于 verify 核对与 archive 终审。
965
1037
 
1038
+ ### 知识沉淀(主代理在本 Wave 收尾,与 module-impact.md 同批)
1039
+ 本 Wave 各 task 子代理报告里若提到**真正的项目特有坑**(跨变更可复用、未来 agent 可能再次踩到、且不是本任务专属细节),由你在 Wave 收尾统一追加到 \`{SPEC_ROOT}/knowledge/uncategorized.md\`:
1040
+ - 条目格式:\`## <一句话标题>\` + 一段说明(坑的来龙去脉 + 规避/解法),末尾标注来源(task-XX)
1041
+ - 不由各 task 子代理分别写(并行改同一文件会互相覆盖),只由主代理在 Wave 收尾统一追加——与 module-impact.md 同一收尾节奏
1042
+ - 不要为了完成任务而硬凑条目;纯新增/纯样式/单点 bug 修复/临时绕过不写;拿不准时不写,宁缺毋滥
1043
+ - execute 末步「知识库审阅」会检查待确认条目并提示用户归类
1044
+
966
1045
  ### 完成后
967
1046
  1. 为每个后端 router task,扫描变更文件提取 API 端点 artifact:
968
1047
  - 在变更文件中搜索所有 router 注册路径(@router.get/post/put/delete)
@@ -232,11 +232,12 @@ export async function cmdValidate(dir, args, opts = {}) {
232
232
  }
233
233
  }
234
234
 
235
- // 4. uncategorized.md 条目数(多了提示需清理)
235
+ // 4. uncategorized.md 条目数(多了提示需清理)。条目行契约是 `## <qlId> | <标题>`
236
+ //(quick 自动候选与手工条目同格式),h3 形态一并兼容;原 `^###` 只数 h3 恒为 0
236
237
  const uncategorizedPath = join(knowledgeDir, 'uncategorized.md')
237
238
  if (existsSync(uncategorizedPath)) {
238
239
  const content = readFileSync(uncategorizedPath, 'utf8')
239
- const entryCount = (content.match(/^###\s+/gm) || []).length
240
+ const entryCount = (content.match(/^#{2,3}\s+\S/gm) || []).length
240
241
  if (entryCount >= 10) {
241
242
  warnings.push({
242
243
  code: 'too_many_uncategorized',
@@ -81,7 +81,14 @@ step 3 --done 审计会把 src 核心文件(CLI / 状态机 / 注入框架等
81
81
  - 本步骤是最后一步,完成后 quick 会话即结束(--change 传递见首步 sessionId 说明)
82
82
 
83
83
  ### ⚠️ 结果摘要模板(必填,CLI 会校验结构)
84
- \`--output\` 是 QUICKLOG「结果:」归档的唯一来源,**必须按此结构给全四项**(逐项一句话,不可用「见前述」替代):
84
+
85
+ 🔧 **推荐四参数形式**(CLI 自动合成结构化模板,杜绝嵌套冒号/缺字段/截断规则踩坑):
86
+
87
+ \`\`\`
88
+ sillyspec run quick --done --change <id> --req "一句话语义化短标题(写「改了什么」)" --cause "为什么这样改(纯新增/配置/样式写「无,纯新增/纯样式」)" --solution "怎么改的" --result "验证情况(测试数 / lint / typecheck / 部署状态)"
89
+ \`\`\`
90
+
91
+ 兼容旧形式 \`--output "需求:… 根因:… 方案:… 结果:…"\`(**必须按此结构给全四项**,逐项一句话,不可用「见前述」替代;缺任一项 \`--done\` 会被拒并提示缺失字段,补全后重跑即可不丢进度):
85
92
 
86
93
  \`\`\`
87
94
  需求:一句话语义化短标题(写「改了什么」)
@@ -90,15 +97,13 @@ step 3 --done 审计会把 src 核心文件(CLI / 状态机 / 注入框架等
90
97
  结果:验证情况(测试数 / lint / typecheck / 部署状态)
91
98
  \`\`\`
92
99
 
93
- ⚠️ **「需求:」就是 QUICKLOG 条目标题**——CLI 提取该字段、截到首个标点(,。;,;)、超 80 字再截断。写成完整需求长句会被截成语义不完整的状语前半段(如「在 X 时,…」标题只剩「在 X 时」),事后还得手改。一句话说清改了什么(如「登录限流修复——INCR 计数误清」),需求背景放「根因:/方案:」字段。
94
-
95
- 缺任一项,\`--done\` 会被拒并提示缺失字段,补全后重跑即可(不丢进度)。
100
+ ⚠️ **「需求:」(= --req)就是 QUICKLOG 条目标题**——CLI 提取该字段、截到首个标点(,。;,;)、超 80 字再截断。写成完整需求长句会被截成语义不完整的状语前半段(如「在 X 时,…」标题只剩「在 X 时」),事后还得手改。一句话说清改了什么(如「登录限流修复——INCR 计数误清」),需求背景放「根因:/方案:」字段。旧形式正文内避免嵌套全角冒号(标签后紧接嵌套冒号会被拆分判定缺字段)——这正是四参数形式消灭的事故面。
96
101
 
97
102
  ### 📎 文件括注 --file-notes(可选,省事后手改文件行)
98
103
  若希望 QUICKLOG「文件:」行直接落盘为多行带括注(而非单行逗号路径),\`--done\` 时加 \`--file-notes\`,CLI 落盘为多行 \`- path(括注)\`,免去事后手改:
99
104
 
100
105
  \`\`\`
101
- sillyspec run quick --done --change <id> --output "需求:… 根因:… 方案:… 结果:…" --file-notes "src/a.js::登录端点串限流 || src/b.js::新建 INCR 计数"
106
+ sillyspec run quick --done --change <id> --req "…" --cause "…" --solution "…" --result "…" --file-notes "src/a.js::登录端点串限流 || src/b.js::新建 INCR 计数"
102
107
  \`\`\`
103
108
 
104
109
  格式:\`path::括注\` 一条,\`||\` 分隔多条(括注可省略只留 \`path\`)。不传则「文件:」行回填审计到的实际改动文件单行(仍可事后手改)。
@@ -116,7 +121,10 @@ sillyspec run quick --done --change <id> --output "需求:… 根因:… 方
116
121
  - 禁止使用 \`git add -A\`
117
122
  - 不要暂存 quick 开始前就已存在的无关改动
118
123
  3. QUICKLOG / tasks.md 记录由 CLI 在本 step 完成时自动收尾(翻「已完成」+ 勾选 task + 轮转),你无需手动建/翻骨架——**QUICKLOG 核对按需**(见下方「QUICKLOG 正文核对」;文件行可用 --file-notes 免手改)
119
- 4. 如果发现项目特有的坑,追加到 \`{SPEC_ROOT}/knowledge/uncategorized.md\`
124
+ 4. 只有遇到**真正的项目特有坑**(跨变更可复用、未来 agent 可能再次踩到、且不是本任务专属细节)时,才追加到 \`{SPEC_ROOT}/knowledge/uncategorized.md\`:
125
+ - 格式:\`## <一句话标题>\` + 一段说明(坑的来龙去脉 + 规避/解法),末尾标注来源 \`(<quicklog-id>)\`
126
+ - 不要为了完成任务而硬凑条目;纯新增/纯样式/单点 bug 修复/临时绕过不写
127
+ - 拿不准时不写,宁缺毋滥
120
128
  5. 任务比预期复杂 → 建议用完整流程
121
129
 
122
130
  ### 模块文档同步
@@ -154,6 +154,7 @@ export const definition = {
154
154
  generator: sillyspec-scan
155
155
  ---
156
156
  \`\`\`
157
+ (\`<git-head-short>\` / \`<now-iso-datetime>\` 两占位符由 CLI 注入 prompt 时自动替换为当前 HEAD 短哈希与时间——照 prompt 里已替换的真实值抄写,勿自跑 git rev-parse;漏 header 事后 \`sillyspec scan-fix-headers\` 一键补 author/created_at)
157
158
  - 覆盖已有 scan 文档前先读取旧 frontmatter;如果旧文档的 \`source_commit\` 与当前 HEAD 不一致,或旧文档 \`updated_at\` 晚于本次 scan 开始时间,不要覆盖。
158
159
  - 如果用户明确传入 \`--force-rescan\`,允许覆盖,但仍需写入新的 \`source_commit\` 和 \`updated_at\`。
159
160
 
@@ -197,57 +197,15 @@ export const definition = {
197
197
  你只需:在 verify-result.md 的「变更风险等级」section 如实记录变更性质;若变更涉及 daemon/backend 跨进程、session/lease/lifecycle 状态机、或部署启动路径,在「Runtime Evidence」section 提供真实集成证据(启动命令、daemon↔backend 调用与日志关键片段、终态断言)。
198
198
  - **集成证据是自报告、CLI 不独立运行时核验**:「Runtime Evidence」由你如实填写,CLI 只校验其**字面存在**(是否含关键词),**不会替你启动 daemon、打真实请求或跑迁移**——它是否名副其实取决于你是否实跑过。务必实跑后据实填写,不得凭堆关键词通过门控。(测试套件对账另算:commands.test 由 CLI 真实执行,那块谎报无效。)
199
199
 
200
- 3. 生成 verify-result.md 文件,保存到 \`.sillyspec/changes/<change-name>/verify-result.md\`
201
- 4. 给出结论:PASS / PASS WITH NOTES / FAIL(受风险门控约束)
200
+ 3. **生成 verify-result.md 骨架(勿从零手写)**:先跑 \`sillyspec verify-probes --change <change-name> --init\`——一条命令生成九章节骨架(已存在不覆盖),其中**探针结果章节已机械预填**(探针 1 的 TODO/FIXME 命中清单、探针 3 的测试覆盖、探针 5 的 API 契约对账表、探针 6 的删除对账三态判定),文件落 \`.sillyspec/changes/<change-name>/verify-result.md\`。你只需把各 \`<!--TODO-->\` 占位替换为语义结论;半语义探针(2 关键词覆盖 / 4 决策追踪)与断言抽查、集成盲区标注由你补在对应 TODO 处
201
+ 4. 给出结论:PASS / PASS WITH NOTES / FAIL(受风险门控约束)——**结论必须写明 PASS/FAIL 字样,留「待填」会被 gate 判不过**
202
202
  5. **核对 module-impact.md**(若 changes/<change>/module-impact.md 存在):对照本次实际代码变更(git diff)与 module-impact.md 的模块影响矩阵,发现不一致(漏标受影响模块 / 影响类型错误 / 实际未触碰的模块被误标)则在 verify-result.md 标注。module-impact 由 plan 首版生成、execute 各 Wave 更新,verify 是最后一次核对机会(archive 仅终审不再生成)。这是 advisory 核对(不阻断 verify 完成),但 module-impact 与实际严重背离应记为风险。
203
203
 
204
- ### verify-result.md 格式
205
- \`\`\`markdown
206
- # 验证报告(Verify Result)
207
-
208
- ## 结论
209
- PASS / PASS WITH NOTES / FAIL
210
-
211
- ## 任务完成度
212
- (逐项检查任务的结果)
213
-
214
- ## 设计一致性
215
- (对照 design.md 的检查结果)
216
-
217
- ## 探针结果
218
- - 未实现标记扫描:...
219
- - 关键词覆盖:...
220
- - 测试覆盖(含断言有效性抽查):...
221
- - 决策追踪覆盖:...
222
- - API 契约对账:...
223
- - 代码删除对账:声明修改/新增却被整文件删除 = 高风险;清单未列出的删除 = 未声明(CLI 会用 git diff --name-status HEAD advisory 复核,仅 warn 不阻断——是否 FAIL blocker 由你诚实判定)
224
-
225
- ## 决策追踪矩阵(如存在 decisions.md)
226
- | 决策 ID | FR | Task | Evidence | 状态 |
227
- |---|---|---|---|---|
228
- | D-001@v1 | FR-01 | task-01 | test/file/path | PASS/PARTIAL/MISSING |
229
-
230
- ## 测试结果
231
- (测试套件执行结果)
232
-
233
- ## 技术债务
234
- (TODO/FIXME/HACK 统计)
235
-
236
- ## 变更风险等级
237
- (自动检测的 change_risk_profile: doc-only / unit-sufficient / contract-required / integration-critical / deployment-critical;若 design.md frontmatter 有 risk_level 显式声明,以声明为准并在此注明「显式声明 = <等级>」+ 理由)
238
-
239
- ## Runtime Evidence(integration-critical / deployment-critical 必填;自报告,CLI 仅校验字面存在、不独立核验,须真实执行过)
240
- 按本次变更实际触碰的运行时组件填写(模板是通用形态——长驻进程 / 服务端点 / 生命周期状态机的证据结构;未涉及的行写「不涉及」并注明原因,勿堆砌无关关键词):
241
- - 长驻进程/服务 启动命令:
242
- - 服务地址(本变更触碰的端点):
243
- - 触发核心路径的请求/命令(附关键响应):
244
- - 进程日志关键片段(证明走了新路径、无降级/回退分支):
245
- - 生命周期终态断言(本变更涉及的状态机:初始态 → 运行态 → 终态,逐个确认到达):
246
- - 失败模式排除(针对本变更声明的风险,逐条说明为何未触发):
247
-
248
- ## 代码审查
249
- (问题列表 + 总体评价)
250
- \`\`\`
204
+ ### verify-result.md 章节结构(骨架已含,占位替换即可)
205
+
206
+ 结论(PASS / PASS WITH NOTES / FAIL)→ 任务完成度 → 设计一致性 → 探针结果(已预填)→ 测试结果 → 决策追踪矩阵(\`| 决策 ID | FR | Task | Evidence | 状态 |\`,存在 decisions.md 才留)→ 技术债务 → 变更风险等级 → Runtime Evidence → 代码审查。
207
+
208
+ **Runtime Evidence 行结构**(integration/deployment-critical 必填;按实际触碰的运行时组件写,未涉及的行写「不涉及」勿堆关键词):长驻进程启动命令 / 触碰的服务端点 / 触发核心路径的请求(附关键响应)/ 进程日志关键片段(证明走了新路径)/ 生命周期终态断言(初始态→运行态→终态)/ 失败模式排除(逐条说明为何未触发)。
251
209
 
252
210
  ### 输出
253
211
  verify-result.md 路径 + 验证报告摘要 + 下一步命令
@@ -525,12 +525,15 @@ export function validateTaskReviews(opts) {
525
525
 
526
526
  // QUAL-01 收口:原为本地 execFileSync('git')(无 safe.directory、与 git-helper 口径分裂),
527
527
  // 现走统一入口;抛错/trim 语义不变,timeout 15000 保留(rev-parse/diff 大仓余量)
528
- function runGit(gitDir, args) {
529
- return git(gitDir, args, { timeout: 15000 })
528
+ function runGit(gitDir, args, opts = {}) {
529
+ return git(gitDir, args, { timeout: 15000, ...opts })
530
530
  }
531
531
 
532
532
  // porcelain 状态行 → 变更文件路径(对齐 shared.js:343 parsePorcelainPath:`XY path`、引号包裹、
533
533
  // rename `R old -> new` 取箭头后)。未 commit 的 working-tree 改动对账用。
534
+ // ⚠️ 入参必须是【未 trim】的原始输出(git(..., {trim:false}))——porcelain 行首空格是状态码
535
+ // 一部分,git() 默认 trim 会把 ' M feature.js' 削成 'M feature.js',slice(3) 再咬掉路径首字符
536
+ // (实测 'eature.js')→ 未提交文件对账全错位(verifyReviewGitEvidence 相交比对误判「完全不相交」)。
534
537
  function parsePorcelainFiles(statusOut) {
535
538
  const files = []
536
539
  for (const line of String(statusOut).split('\n')) {
@@ -639,7 +642,7 @@ export function verifyReviewGitEvidence(review, gitDir, cache = null) {
639
642
  try {
640
643
  let wtStatus = c.wtStatus.get(gitDir)
641
644
  if (wtStatus === undefined) {
642
- wtStatus = runGit(gitDir, ['status', '--porcelain'])
645
+ wtStatus = runGit(gitDir, ['status', '--porcelain'], { trim: false })
643
646
  c.wtStatus.set(gitDir, wtStatus)
644
647
  }
645
648
  if (wtStatus && wtStatus.trim().length > 0) {
@@ -983,7 +986,30 @@ export async function generateTaskReviewDrafts({ changeName, cwd, platformOpts =
983
986
  }
984
987
 
985
988
  // base..head diff 文件集(worktree-aware;null=git 不可用,[]=无 commit diff)
986
- const diffFiles = resolveVerifyChangedFiles(cwd, changeName)
989
+ let diffFiles = resolveVerifyChangedFiles(cwd, changeName)
990
+ // ⚠️ 并入 worktree 未提交改动(坑 draft-attribution-uncommitted-worktree,2026-08-21 实证):
991
+ // 子代理默认不 commit(execute 复盘 a 同源事实),真实改动全在 worktree working-tree——只看
992
+ // base..HEAD commit diff 时归属恒空,9/9 草稿全成「无归属」靠主代理手写升级。与
993
+ // verifyReviewGitEvidence 的 working-tree 并入口径同源:status --porcelain 文件(排除
994
+ // .sillyspec/ 运行时产物)并入归属集,按 allowed_paths 正常路径归属;review.head 仍为 HEAD
995
+ // commit——evidence 校验对「commit diff 空 + working-tree 有改动」明示不判伪造,语义一致。
996
+ try {
997
+ const wm = new WorktreeManager({ cwd })
998
+ const meta = wm.getMeta(changeName)
999
+ if (meta) {
1000
+ const wtGitDir = (meta.worktreePath && meta.mode !== 'in-place-fallback' && existsSync(meta.worktreePath))
1001
+ ? meta.worktreePath
1002
+ : cwd
1003
+ const wtStatus = runGit(wtGitDir, ['status', '--porcelain'], { trim: false })
1004
+ const wtFiles = parsePorcelainFiles(wtStatus)
1005
+ .map(p => String(p).replace(/\\/g, '/'))
1006
+ .filter(p => p !== '.sillyspec' && !p.startsWith('.sillyspec/'))
1007
+ if (wtFiles.length > 0) {
1008
+ diffFiles = [...new Set([...(Array.isArray(diffFiles) ? diffFiles : []), ...wtFiles])]
1009
+ console.log(`[sillyspec] 草稿归属并入 ${wtFiles.length} 个 worktree 未提交文件(子代理未 commit 的改动按 allowed_paths 归属)`)
1010
+ }
1011
+ }
1012
+ } catch { /* working-tree 并入失败退回 commit diff 口径(fail-open,不阻断草稿) */ }
987
1013
  // 单仓模式(无 ctx):主仓无 diff 即无任何 task 可生成 → 提前返回(原逻辑零回归)。
988
1014
  // 有 ctx:主仓无 diff 不阻断——跨仓 task 的 diff 在跨仓仓根独立取(per-task),主仓 task 自然跳过(空 changedFiles)。
989
1015
  if (!ctx && (!diffFiles || diffFiles.length === 0)) {
@@ -1150,6 +1176,194 @@ export async function generateTaskReviewDrafts({ changeName, cwd, platformOpts =
1150
1176
  return { generated, skipped, noAttribution: noAttributionCount, unattributed, executeRunId }
1151
1177
  }
1152
1178
 
1179
+ /**
1180
+ * adoptTaskReviewMechanics —— 已存在 review.json 的 mechanics 字段一键重算代填(保 verdict)。
1181
+ *
1182
+ * 背景(2026-08-21 agent-手工产出审计项①):review.json 里 schemaVersion/task/base/head/
1183
+ * changedFiles(/repo) 全部可由 CLI 从 git + task 卡机械推导(generateTaskReviewDrafts 已证明),
1184
+ * agent/子代理手算 hash、手抄 diff 清单是纯错误面——而 gate(verifyReviewGitEvidence)校验的
1185
+ * 恰是这些字段。与草稿生成互补:草稿只补「缺失」,adopt 修「已存在但 mechanics 错/缺」——
1186
+ * 保留人工语义字段(specVerdict/qualityVerdict/requiredEvidence/reviewerNotes),只重写 mechanics。
1187
+ *
1188
+ * verdict 保护策略(不猜 agent 本意):
1189
+ * - 合法 verdict(VALID_VERDICTS)原样保留;
1190
+ * - 非法/缺失 verdict 降级 cannot_verify,reviewerNotes 追加标记,requiredEvidence 缺失补占位
1191
+ * (schema 要求 cannot_verify 必带非空 requiredEvidence);
1192
+ * - JSON 解析失败的 review 不救(无法可信保留任何字段),报出来留给 agent 修 JSON 后重跑。
1193
+ *
1194
+ * fail-open:与草稿生成同契约——缺数据返回统计与 reasons,不抛。
1195
+ *
1196
+ * @param {{ changeName: string, cwd: string, platformOpts?: object, ctx?: object|null }} opts
1197
+ * @returns {Promise<{ adopted: number, unchanged: number, skipped: number, missing: number,
1198
+ * downgraded: string[], reasons: string[], executeRunId?: string }>}
1199
+ */
1200
+ export async function adoptTaskReviewMechanics({ changeName, cwd, platformOpts = {}, ctx = null }) {
1201
+ const specBase = platformOpts.specRoot || join(cwd, '.sillyspec')
1202
+ const runtimeRoot = resolveRuntimeRoot(platformOpts, specBase)
1203
+ const tasksDir = join(specBase, 'changes', changeName, 'tasks')
1204
+ const reasons = []
1205
+
1206
+ if (!changeName) {
1207
+ return { adopted: 0, unchanged: 0, skipped: 0, missing: 0, downgraded: [], reasons: ['无 changeName'] }
1208
+ }
1209
+ if (!existsSync(tasksDir)) {
1210
+ return { adopted: 0, unchanged: 0, skipped: 0, missing: 0, downgraded: [], reasons: ['无 tasks/ 目录'] }
1211
+ }
1212
+
1213
+ // executeRunId:与草稿/gate 同源 marker。缺 marker 不新建 run——adopt 修的是当前 run 里
1214
+ // 已存在的 review,marker 丢失说明无「当前 run」语义,无对象可修(gate 对 marker 缺失放行,
1215
+ // 下游 Task Review Gate 兜底,这里同口径不造 run)。
1216
+ const runIdFile = join(runtimeRoot, 'current-execute-run-id-' + changeName)
1217
+ let executeRunId = ''
1218
+ try {
1219
+ if (existsSync(runIdFile)) {
1220
+ const c = readFileSync(runIdFile, 'utf8').trim()
1221
+ if (isValidExecuteRunId(c)) executeRunId = c
1222
+ }
1223
+ } catch {}
1224
+ if (!executeRunId) {
1225
+ return { adopted: 0, unchanged: 0, skipped: 0, missing: 0, downgraded: [], reasons: ['无 execute run marker——无当前 run,adopt 无对象'] }
1226
+ }
1227
+
1228
+ // base/head + diff:与 generateTaskReviewDrafts 同源口径(meta 锡点 + worktree rev-parse +
1229
+ // resolveVerifyChangedFiles),保证 adopt 产物与 gate 校验输入一致。
1230
+ const diffFiles = resolveVerifyChangedFiles(cwd, changeName)
1231
+ let base = null
1232
+ let head = null
1233
+ let reviewGitDir = cwd
1234
+ try {
1235
+ const wm = new WorktreeManager({ cwd })
1236
+ const meta = wm.getMeta(changeName)
1237
+ base = (meta && (meta.baselineCommit || meta.baseHash)) || null
1238
+ if (meta && meta.worktreePath && meta.mode !== 'in-place-fallback' && existsSync(meta.worktreePath)) {
1239
+ reviewGitDir = meta.worktreePath
1240
+ }
1241
+ } catch {}
1242
+ if (base) {
1243
+ try {
1244
+ head = runGit(reviewGitDir, ['rev-parse', 'HEAD'])
1245
+ } catch {
1246
+ head = null
1247
+ }
1248
+ }
1249
+
1250
+ const taskFiles = readdirSync(tasksDir).filter(f => /^task-\d+\.md$/.test(f)).sort()
1251
+ let adopted = 0
1252
+ let unchanged = 0
1253
+ let skipped = 0
1254
+ let missing = 0
1255
+ const downgraded = []
1256
+
1257
+ for (const tf of taskFiles) {
1258
+ const taskId = tf.replace(/\.md$/, '')
1259
+ const reviewPath = join(runtimeRoot, 'execute-runs', executeRunId, 'tasks', taskId, 'review.json')
1260
+ if (!existsSync(reviewPath)) {
1261
+ missing++
1262
+ continue
1263
+ }
1264
+
1265
+ const existing = readReview(reviewPath)
1266
+ if (existing.parseError) {
1267
+ skipped++
1268
+ reasons.push(`${taskId}: JSON 解析失败,adopt 不猜字段——先修 JSON 再重跑`)
1269
+ continue
1270
+ }
1271
+ const prev = existing.review || {}
1272
+
1273
+ const content = readFileSync(join(tasksDir, tf), 'utf8')
1274
+ const allowedPaths = parseAllowedPaths(content)
1275
+ const taskRepo = normalizeRepoKey(parseRepo(content))
1276
+ const crossEntry = (ctx && taskRepo !== DEFAULT_REPO_KEY) ? ctx.resolve(taskRepo) : null
1277
+ let taskBase = base
1278
+ let taskHead = head
1279
+ let taskChangedFiles
1280
+ let draftRepo
1281
+
1282
+ if (crossEntry) {
1283
+ const baseCommit = parseBaseCommit(content)
1284
+ const headCommit = parseHeadCommit(content)
1285
+ if (!baseCommit || !headCommit) {
1286
+ skipped++
1287
+ reasons.push(`${taskId}: 跨仓 task 卡缺 base/head 锡点,无法 adopt`)
1288
+ continue
1289
+ }
1290
+ taskBase = baseCommit
1291
+ taskHead = headCommit
1292
+ draftRepo = taskRepo
1293
+ let crossDiffFiles = []
1294
+ try {
1295
+ const out = runGit(crossEntry.gitDir, ['diff', '--name-only', `${baseCommit}..${headCommit}`])
1296
+ crossDiffFiles = out ? out.split('\n').filter(Boolean).map(p => p.replace(/\\/g, '/')) : []
1297
+ } catch {
1298
+ skipped++
1299
+ reasons.push(`${taskId}: 跨仓 git diff 失败(${crossEntry.gitDir})`)
1300
+ continue
1301
+ }
1302
+ taskChangedFiles = allowedPaths.length > 0
1303
+ ? crossDiffFiles.filter(f => allowedPaths.some(p => pathMatches(f, p)))
1304
+ : crossDiffFiles
1305
+ } else {
1306
+ const mainDiff = Array.isArray(diffFiles) ? diffFiles : []
1307
+ taskChangedFiles = allowedPaths.length > 0
1308
+ ? mainDiff.filter(f => allowedPaths.some(p => pathMatches(f, p)))
1309
+ : []
1310
+ }
1311
+
1312
+ if (!crossEntry && (!taskBase || !taskHead)) {
1313
+ skipped++
1314
+ reasons.push(`${taskId}: 主仓缺 worktree meta base/head,无法 adopt`)
1315
+ continue
1316
+ }
1317
+
1318
+ const merged = { ...prev }
1319
+ merged.schemaVersion = REVIEW_SCHEMA_VERSION
1320
+ merged.task = taskId
1321
+ merged.base = taskBase
1322
+ merged.head = taskHead
1323
+ merged.changedFiles = taskChangedFiles
1324
+ if (draftRepo) merged.repo = draftRepo
1325
+
1326
+ let verdictTouched = false
1327
+ for (const v of ['specVerdict', 'qualityVerdict']) {
1328
+ if (!VALID_VERDICTS.includes(merged[v])) {
1329
+ merged[v] = 'cannot_verify'
1330
+ verdictTouched = true
1331
+ }
1332
+ }
1333
+ if (verdictTouched) downgraded.push(taskId)
1334
+ let evidenceFilled = false
1335
+ if ((merged.specVerdict === 'cannot_verify' || merged.qualityVerdict === 'cannot_verify')
1336
+ && (!Array.isArray(merged.requiredEvidence) || merged.requiredEvidence.length === 0)) {
1337
+ merged.requiredEvidence = [`adopt 代填:原 review 缺 requiredEvidence(verdict=${merged.specVerdict}/${merged.qualityVerdict}),待人工复核补证据`]
1338
+ evidenceFilled = true
1339
+ }
1340
+ if (verdictTouched) {
1341
+ merged.reviewerNotes = String(merged.reviewerNotes || '') + (merged.reviewerNotes ? '; ' : '')
1342
+ + 'adopt: 原 verdict 非法/缺失已降级 cannot_verify(不猜本意)'
1343
+ }
1344
+
1345
+ const mechanicsChanged = prev.schemaVersion !== merged.schemaVersion
1346
+ || prev.base !== merged.base
1347
+ || prev.head !== merged.head
1348
+ || JSON.stringify(prev.changedFiles ?? null) !== JSON.stringify(merged.changedFiles)
1349
+ || prev.repo !== merged.repo
1350
+
1351
+ try {
1352
+ if (mechanicsChanged || verdictTouched || evidenceFilled) {
1353
+ writeFileSync(reviewPath, JSON.stringify(merged, null, 2) + '\n')
1354
+ adopted++
1355
+ } else {
1356
+ unchanged++
1357
+ }
1358
+ } catch (e) {
1359
+ skipped++
1360
+ reasons.push(`${taskId}: 写回失败 ${e.message}`)
1361
+ }
1362
+ }
1363
+
1364
+ return { adopted, unchanged, skipped, missing, downgraded, reasons, executeRunId }
1365
+ }
1366
+
1153
1367
  /**
1154
1368
  * 获取当前(或最新)execute run id
1155
1369
  * 从 runtime 目录下查找 execute-runs/ 子目录