sillyspec 3.32.8 → 3.32.10

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 (63) hide show
  1. package/.claude/skills/sillyspec-brainstorm/SKILL.md +2 -2
  2. package/.claude/skills/sillyspec-execute/SKILL.md +2 -2
  3. package/.claude/skills/sillyspec-flow/SKILL.md +1 -1
  4. package/.claude/skills/sillyspec-plan/SKILL.md +2 -2
  5. package/AGENTS.md +5 -4
  6. package/assets/command-cards/flow.md +9 -4
  7. package/package.json +1 -1
  8. package/src/change-list.js +13 -0
  9. package/src/commit-guard.js +70 -2
  10. package/src/commit-suggest.js +45 -4
  11. package/src/config-schema.js +14 -0
  12. package/src/cross-repo-reconcile.js +54 -1
  13. package/src/decision-distill.js +77 -21
  14. package/src/decision-renumber.js +157 -0
  15. package/src/deleted-ledger.js +75 -0
  16. package/src/design-facts.js +5 -3
  17. package/src/dispatch/backends/local-agent.js +1 -1
  18. package/src/dispatch/backends/sillyhub-mcp.js +1 -1
  19. package/src/docs-check.js +30 -15
  20. package/src/docs-debt.js +3 -2
  21. package/src/doctor-diagnostics.js +3 -1
  22. package/src/flow-draft.js +24 -2
  23. package/src/flow-parity.js +32 -2
  24. package/src/flow.js +427 -69
  25. package/src/fr-index.js +15 -1
  26. package/src/hooks/worktree-guard.js +3 -3
  27. package/src/index.js +22 -10
  28. package/src/knowledge-digest.js +23 -6
  29. package/src/knowledge-graph.js +92 -14
  30. package/src/knowledge-match.js +34 -9
  31. package/src/knowledge-stats.js +12 -13
  32. package/src/knowledge-vector.js +98 -7
  33. package/src/machine-interface.js +26 -0
  34. package/src/progress/change-registry.js +7 -0
  35. package/src/progress.js +67 -0
  36. package/src/quicklog.js +6 -1
  37. package/src/redomain.js +27 -0
  38. package/src/route-hindsight.js +34 -2
  39. package/src/run/archive-distill.js +3 -0
  40. package/src/run/command.js +40 -44
  41. package/src/run/complete-handlers.js +8 -3
  42. package/src/run/complete.js +129 -63
  43. package/src/run/gates.js +94 -90
  44. package/src/run/prompt.js +13 -3
  45. package/src/run/shared.js +19 -0
  46. package/src/run/stage.js +1 -1
  47. package/src/scope-audit.js +2 -2
  48. package/src/sentinel-assertions.js +43 -1
  49. package/src/stage-contract.js +26 -3
  50. package/src/stage-review.js +44 -11
  51. package/src/stage-templates.js +2 -0
  52. package/src/stages/brainstorm.js +24 -12
  53. package/src/stages/execute.js +1 -1
  54. package/src/stages/explore.js +3 -5
  55. package/src/stages/knowledge.js +56 -5
  56. package/src/sync.js +98 -2
  57. package/src/task-tick.js +12 -0
  58. package/src/timeline.js +60 -19
  59. package/src/verify-postcheck.js +83 -6
  60. package/src/verify-probes.js +61 -18
  61. package/templates/agents-instruction.md +5 -4
  62. package/templates/prompts/testcase-design.md +5 -0
  63. package/src/stages/brainstorm-auto.js +0 -286
@@ -0,0 +1,157 @@
1
+ /**
2
+ * decision-renumber.js — 存量决策号一次性迁移(2026-10-14-decision-legacy-renumber)。
3
+ *
4
+ * 背景:epoch 全局化(2026-10-14-decision-global-id-epoch)落地后新蒸馏条目自动发全局号,但存量
5
+ * 701 条 legacy 局部号节段无跨变更承接机制、不会自然退役。用户裁决一次性迁移(翻「存量不回填」
6
+ * 旧案——fr-index D-007 是设计取舍非技术禁忌,翻案理由随本变更蒸馏留痕)。epoch 读面(双形态解析/
7
+ * 原编号字段/originId 匹配)已就位——迁移产物所有消费方零改动即认识。
8
+ *
9
+ * 纯函数两段(CLI 层 src/stages/knowledge.js case 'renumber' 负责 fs 与原子写):
10
+ * planRenumber(knowledgeRoot)——扫描全部域文件产出迁移计划:
11
+ * · legacy 节段(局部号标题 D-\d+ 且无原编号字段)按「文件字母序 × 节段序」发号
12
+ * D-<域>-NNN(域=所在文件名去 .md;NNN=该域全域既有 epoch 号 max+1 起批内递增);
13
+ * · fanout 组(同变更同号同版本跨文件副本)单一身份——全组共用一号,号域=字母序首文件域;
14
+ * · 无变更行条目不分组(同号无变更 ≠ 同一决策——backend.md 双 D-001@v1 无变更行实证是
15
+ * 两条不同决策),逐节独立发号;
16
+ * · epoch 形态节段(全局号标题或原编号在场)零触碰——幂等判定面。
17
+ * applyRenumberPlan(contentByFile, plan)——按计划重写:标题行旧 id → 新全局号、
18
+ * 「原编号:<旧号>」插入变更行后(无变更行则状态行后,再退标题行后)、其余行逐字节保留、
19
+ * 节段次序不变;joinKnowledgeFile 复用既有归一落盘形态。
20
+ */
21
+ import { readFileSync, readdirSync, existsSync } from 'node:fs'
22
+ import { join } from 'node:path'
23
+ import { splitKnowledgeSections, joinKnowledgeFile } from './decision-distill.js'
24
+
25
+ const EPOCH_NUMBER_RE = /^D-[A-Za-z0-9_\-\u4e00-\u9fff]+-\d+$/
26
+ const LOCAL_NUMBER_RE = /^D-\d+$/
27
+
28
+ /** 扫描 decisions 域文件集(files 元素含 splitKnowledgeSections 产物,零写盘) */
29
+ function scanDomainFiles(knowledgeRoot) {
30
+ const decisionsDir = join(knowledgeRoot, 'decisions')
31
+ const files = []
32
+ if (!existsSync(decisionsDir)) return files
33
+ for (const f of readdirSync(decisionsDir).sort()) {
34
+ if (!f.endsWith('.md')) continue
35
+ const content = readFileSync(join(decisionsDir, f), 'utf8')
36
+ const { preamble, sections } = splitKnowledgeSections(content)
37
+ files.push({ file: `decisions/${f}`, domain: f.replace(/\.md$/, ''), content, preamble, sections })
38
+ }
39
+ return files
40
+ }
41
+
42
+ /**
43
+ * 纯函数(只读扫描):产出迁移计划。
44
+ * @param {string} knowledgeRoot knowledge 目录
45
+ * @returns {{ files: Array<{file, domain, content, ops: Array<{secIdx, fromId, toNumber}>}>,
46
+ * stats: { legacySections, fanoutGroups, fanoutCopies, mintedNumbers, noChangeEntries, untouchedEpoch, perDomainStart: object } }}
47
+ * perDomainStart = 各发号域的起始号(既有 epoch max+1);ops 为空的文件也保留(content 供 CLI 指纹校验)
48
+ */
49
+ export function planRenumber(knowledgeRoot) {
50
+ const files = scanDomainFiles(knowledgeRoot)
51
+ // 各域既有 epoch 号 max(发号起点——不与已发号冲突)
52
+ const domainMax = new Map()
53
+ let untouchedEpoch = 0
54
+ for (const { sections } of files) {
55
+ for (const s of sections) {
56
+ const m = /^D-([A-Za-z0-9_\-\u4e00-\u9fff]+)-(\d+)$/.exec(s.number)
57
+ if (m) domainMax.set(m[1], Math.max(domainMax.get(m[1]) || 0, parseInt(m[2], 10)))
58
+ }
59
+ }
60
+ const isLegacy = (s) => LOCAL_NUMBER_RE.test(s.number) && !s.origin && !EPOCH_NUMBER_RE.test(s.number)
61
+ for (const { sections } of files) for (const s of sections) if (!isLegacy(s)) untouchedEpoch++
62
+ // fanout 分组:同变更(非空)同号同版本跨文件 → 单一身份(组号=字母序首文件域——files 已按文件名排序)
63
+ const groupKeyOf = (s) => `${s.change}#${s.number}@v${s.version}`
64
+ const groups = new Map()
65
+ files.forEach((f) => f.sections.forEach((s) => {
66
+ if (!isLegacy(s) || !s.change) return
67
+ const k = groupKeyOf(s)
68
+ if (!groups.has(k)) groups.set(k, { domain: f.domain, members: [] })
69
+ groups.get(k).members.push({ file: f.file, secIdx: f.sections.indexOf(s) })
70
+ }))
71
+ const fanoutGroups = [...groups.values()].filter((g) => g.members.length >= 2)
72
+ const fanoutCopies = fanoutGroups.reduce((a, g) => a + g.members.length, 0)
73
+ // 组号预分配(确定性:组键排序)
74
+ const minted = new Map()
75
+ const nextFor = (domain) => {
76
+ const cur = Math.max(domainMax.get(domain) || 0, minted.get(domain) || 0) + 1
77
+ minted.set(domain, cur)
78
+ return `D-${domain}-${String(cur).padStart(3, '0')}`
79
+ }
80
+ const groupNumber = new Map()
81
+ for (const k of [...groups.keys()].sort()) {
82
+ const g = groups.get(k)
83
+ if (g.members.length >= 2) groupNumber.set(k, nextFor(g.domain))
84
+ }
85
+ // 逐文件逐节段:组员复用组号,其余 legacy 独立发号(域=所在文件域)
86
+ const perFileOps = new Map()
87
+ let legacySections = 0
88
+ let mintedNumbers = 0
89
+ let noChangeEntries = 0
90
+ for (const f of files) {
91
+ const ops = []
92
+ f.sections.forEach((s, si) => {
93
+ if (!isLegacy(s)) return
94
+ legacySections++
95
+ if (!s.change) noChangeEntries++
96
+ const fromId = `${s.number}@v${s.version}`
97
+ let toNumber
98
+ if (s.change && groups.get(groupKeyOf(s))?.members.length >= 2) {
99
+ toNumber = groupNumber.get(groupKeyOf(s))
100
+ } else {
101
+ toNumber = nextFor(f.domain)
102
+ mintedNumbers++
103
+ }
104
+ ops.push({ secIdx: si, fromId, toNumber })
105
+ })
106
+ perFileOps.set(f.file, ops)
107
+ }
108
+ mintedNumbers += groupNumber.size
109
+ const perDomainStart = {}
110
+ for (const d of new Set([...files.map((f) => f.domain), ...domainMax.keys(), ...minted.keys()])) {
111
+ perDomainStart[d] = (domainMax.get(d) || 0) + 1
112
+ }
113
+ return {
114
+ files: files.map((f) => ({ file: f.file, domain: f.domain, content: f.content, ops: perFileOps.get(f.file) || [] })),
115
+ stats: { legacySections, fanoutGroups: fanoutGroups.length, fanoutCopies, mintedNumbers, noChangeEntries, untouchedEpoch, perDomainStart },
116
+ }
117
+ }
118
+
119
+ /**
120
+ * 纯函数(零 fs):按计划重写。contentByFile 为计划时点内容(CLI 层先做指纹校验防他侧写入被覆盖)。
121
+ * @param {Map<string,string>} contentByFile file → 全文
122
+ * @param {object} plan planRenumber 产物
123
+ * @returns {Map<string,string>} 仅有 op 的 file → 重写后全文
124
+ */
125
+ export function applyRenumberPlan(contentByFile, plan) {
126
+ const out = new Map()
127
+ for (const fe of plan.files) {
128
+ if (!fe.ops || fe.ops.length === 0) continue
129
+ const content = contentByFile.get(fe.file)
130
+ if (content == null) continue
131
+ const { preamble, sections } = splitKnowledgeSections(content)
132
+ const byIdx = new Map(fe.ops.map((o) => [o.secIdx, o]))
133
+ for (let i = 0; i < sections.length; i++) {
134
+ const op = byIdx.get(i)
135
+ if (!op) continue
136
+ const s = sections[i]
137
+ // 标题行:首个旧 id 出现即节头 id(其余行不动)
138
+ s.lines[0] = s.lines[0].replace(op.fromId, `${op.toNumber}@v${s.version}`)
139
+ // 原编号行:变更行后 > 状态行后 > 标题行后(不破「文件:紧跟锚点」行序红线)
140
+ let at = 1
141
+ const vi = s.lines.findIndex((l) => /^变更[::]/.test(l))
142
+ if (vi > -1) at = vi + 1
143
+ else {
144
+ const si = s.lines.findIndex((l) => /^状态[::]/.test(l))
145
+ if (si > -1) at = si + 1
146
+ }
147
+ s.lines.splice(at, 0, `原编号:${op.fromId}`)
148
+ s.id = `${op.toNumber}@v${s.version}`
149
+ s.number = op.toNumber
150
+ s.origin = op.fromId
151
+ }
152
+ out.set(fe.file, joinKnowledgeFile(preamble, sections))
153
+ }
154
+ return out
155
+ }
156
+
157
+ export default { planRenumber, applyRenumberPlan }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * 删除台账(deleted-changes.jsonl)——终态转换的追加式账本。
3
+ *
4
+ * 背景(2026-10-13-new-change-e5a27813 幽灵变更事故,multi-agent-platform 仓实证):
5
+ * X1 墓碑链(sync.js 对 DB status='deleted'/'archived' 行追加墓碑 POST)依赖 DB 行存活;
6
+ * DB 行被外部手段物理清除后(rm + 裸 SQL,任何 sillyspec 官方路径都不物理删行),后续
7
+ * platform sync --change 一律「变更无进度数据」空转,平台行永挂 in_progress/active。
8
+ * 台账让「存在过且已达终态」这一事实脱离 DB 行存活而可查:deleteChange/unregisterChange
9
+ * 在事务提交后追加一行;sync() 的行缺失分支据此合成墓碑补推(见 sync.js 台账分支)。
10
+ *
11
+ * 形态契约:`.sillyspec/.runtime/deleted-changes.jsonl`,每行一个 JSON 对象
12
+ * { name, status: 'deleted'|'archived', at: ISO };追加-only(读取侧 latest-wins),
13
+ * 不旋转不清空——每行百余字节、终态转换频度=归档/删除频度,增长可忽略。
14
+ * 坏行容错:单行 JSON 解析失败跳过该行(外部编辑/半写截断不拖垮读取)。
15
+ */
16
+ import { appendFileSync, existsSync, readFileSync } from 'node:fs'
17
+ import { join } from 'node:path'
18
+
19
+ const LEDGER_FILENAME = 'deleted-changes.jsonl'
20
+
21
+ function ledgerPath(specDir) {
22
+ return join(specDir, '.runtime', LEDGER_FILENAME)
23
+ }
24
+
25
+ /**
26
+ * 追加一条终态台账。fail-open:任何写入失败只 warn 不抛——台账是 DB 行存活时的
27
+ * 冗余信息(X1 链已闭环),它的缺失最多让「行被外部清除」这一非官方场景退回平台
28
+ * 7 天 reparse 兜底,不值得阻断删除/归档主流程。
29
+ * @param {string} specDir 已解析的 .sillyspec 根(与 sillyspec.db 同根)
30
+ * @param {string} changeName 变更名
31
+ * @param {'deleted'|'archived'} status 终态
32
+ * @param {string} [at] ISO 时间戳(缺省当前时间)
33
+ */
34
+ export function appendDeletedLedger(specDir, changeName, status, at = new Date().toISOString()) {
35
+ if (!specDir || !changeName || (status !== 'deleted' && status !== 'archived')) {
36
+ console.warn(`⚠️ appendDeletedLedger: 参数不完整(name=${changeName} status=${status}),跳过台账追加`)
37
+ return
38
+ }
39
+ try {
40
+ appendFileSync(ledgerPath(specDir), JSON.stringify({ name: changeName, status, at }) + '\n', 'utf8')
41
+ } catch (err) {
42
+ console.warn(`⚠️ 删除台账追加失败(fail-open 不阻断主流程): ${changeName}: ${(err && err.message) || err}`)
43
+ }
44
+ }
45
+
46
+ /**
47
+ * 查询变更的最新终态台账条目(同名多条取最后一条——latest-wins)。
48
+ * 容错:文件不存在 → null;坏行跳过不抛。
49
+ * @param {string} specDir 已解析的 .sillyspec 根
50
+ * @param {string} changeName 变更名
51
+ * @returns {{name: string, status: string, at: string} | null}
52
+ */
53
+ export function lookupDeletedLedger(specDir, changeName) {
54
+ if (!specDir || !changeName) return null
55
+ const p = ledgerPath(specDir)
56
+ if (!existsSync(p)) return null
57
+ let text
58
+ try {
59
+ text = readFileSync(p, 'utf8')
60
+ } catch {
61
+ return null // 读失败按无台账处理(消费侧退回普通「无进度数据」路径)
62
+ }
63
+ let hit = null
64
+ for (const line of text.split('\n')) {
65
+ const trimmed = line.trim()
66
+ if (!trimmed) continue
67
+ try {
68
+ const obj = JSON.parse(trimmed)
69
+ if (obj && obj.name === changeName && (obj.status === 'deleted' || obj.status === 'archived')) {
70
+ hit = { name: obj.name, status: obj.status, at: obj.at }
71
+ }
72
+ } catch { /* 坏行跳过:半写截断/外部编辑不拖垮整本台账 */ }
73
+ }
74
+ return hit
75
+ }
@@ -296,8 +296,10 @@ export function generateDesignSkeleton({ changeName, decisionsText, author, now
296
296
  //「骨架优先,仅手写补文档才手填元数据」的依据(validateMetadata 未来可据此只认 CLI 戳)
297
297
  lines.push(`generated_by: sillyspec-design-init`)
298
298
  // scale 不预填(2026-09-29-brainstorm-exit-thin-default):此前预填 large 是静默厚默认——
299
- // 不主动改就是五阶段;现由 brainstorm Step 8 规模评估按复杂度/上下文轴落值,拿不准默认 small
300
- lines.push('scale: "" # TODO:Step 8 规模评估落值——单上下文可吞吐=small(flow start 收编);需 Wave 编排/上下文分片/多阶段治理=large(run plan);拿不准 small')
299
+ // 不主动改就是五阶段;现由 brainstorm Step 8 规模评估按复杂度/上下文轴落值,拿不准默认 small。
300
+ // 反判据口径(2026-10-14-scale-large-evidence):跨端/多仓/顺序依赖不算 Wave 证据,判 large
301
+ // 须写明「可真并行任务簇+单上下文吃不下」两条同时成立——deploy-release-notes 镜像误判实证
302
+ lines.push('scale: "" # TODO:Step 8 规模评估落值——单上下文可吞吐=small(flow start 收编);需 Wave 真并行编排/上下文分片/多阶段治理=large(run plan,须写明 Wave 证据:可真并行任务簇+单上下文吃不下;跨端/多仓/顺序依赖不算);拿不准 small')
301
303
  lines.push('---')
302
304
  lines.push('')
303
305
  lines.push(`# 设计文档(Design)— ${change}`)
@@ -385,7 +387,7 @@ export function generateDesignSkeleton({ changeName, decisionsText, author, now
385
387
  lines.push('- [ ] frontmatter 字段齐全(author/created_at/scale)')
386
388
  lines.push('- [ ] 引用所有当前版本 D-xxx@vN')
387
389
  lines.push('- [ ] 涉及生命周期关键词时含「生命周期契约表」或紧邻豁免短语')
388
- lines.push('- [ ] UI 原型分级核对(涉前端文件时变更目录应有 prototype-*.html 或跳过原因记入风险登记)')
390
+ lines.push('- [ ] UI 原型分级核对(涉前端文件时变更目录应有 prototype-*.html——新生成(定稿=与实现同方言的真码产物,手绘仅限粗选)或登记复用(复用须按 prototype-<名称>.html 命名,execute 才注入)或跳过原因记入风险登记)')
389
391
  lines.push('- [ ] 不确定的问题标注「⚠️ 自审存疑」')
390
392
  lines.push('')
391
393
 
@@ -95,7 +95,7 @@ export function renderLocalInstruction(contract) {
95
95
 
96
96
  const modeLine = readOnly
97
97
  ? '5. **只读模式(readOnly=true)**:本子代理只读 / 审查 / 写 review.json,**不改源码**'
98
- : '5. **写模式(readOnly=false)**:按蓝图实现源码(TDD:先读后写 → 写测试 → 写实现)'
98
+ : '5. **写模式(readOnly=false)**:按蓝图实现源码(TDD:先读后写 → 写测试 → 写实现,按层适用——纯逻辑严格先行、行为契约断用户所见、样式/纯配置不写,判据见「测试用例设计」)'
99
99
 
100
100
  return `### 派发后端:Local(本机 Agent tool)— 默认与降级路径
101
101
 
@@ -218,7 +218,7 @@ dispatch_worker 的 \`worker_prompt\` 参数必须传以下覆写文本(路径
218
218
 
219
219
  \`\`\`
220
220
  你是 SillySpec execute 的 worker,在指定 worktree 干活。铁律:
221
- 1. 按 objective + 蓝图(tasks/task-XX.md / design.md)实现任务(TDD:先读后写 → 写测试 → 写实现)
221
+ 1. 按 objective + 蓝图(tasks/task-XX.md / design.md)实现任务(TDD:先读后写 → 写测试 → 写实现,按层适用——纯逻辑严格先行、行为契约断用户所见、样式/纯配置不写,判据见「测试用例设计」)
222
222
  2. **绝不执行 git add / git commit / git push**:改动留在工作区(staged 或 unstaged 都行),
223
223
  交 SillySpec 主体 git diff 回收(D-004)
224
224
  3. 不超出 allowedPaths,不碰其他 task 的文件
package/src/docs-check.js CHANGED
@@ -1038,12 +1038,20 @@ export function readDecisionRulesConfig(projectRoot) {
1038
1038
  }
1039
1039
  }
1040
1040
 
1041
- /** 豁免键匹配(本变更范围先支持键匹配,非子串):规则级 `decisions.<id>.<kind>` 精确命中,
1042
- * 或条目级 `decisions.<id>` 伞形命中(一条豁免该决策全部规则)。无 decisions. 前缀的键不参与。 */
1043
- function isDecisionExempt(knownFailures, entryId, kind) {
1044
- const ruleKey = `decisions.${entryId}.${kind}`
1045
- const entryKey = `decisions.${entryId}`
1046
- return knownFailures.some(k => k === ruleKey || k === entryKey)
1041
+ /** 豁免键匹配(键匹配非子串):域限定键 `decisions.<域>/<id>.<kind>`(规则级精确)与
1042
+ * `decisions.<域>/<id>`(条目级伞形)——2026-10-14-decision-id-p2-hardening:裸键无域限定,
1043
+ * 一条 decisions.<id> 可静默豁免全库同号条目(D-001@v1 ×296 潜伏面),域限定键收窄命中面;
1044
+ * 裸键 `decisions.<id>[.<kind>]` 保留兼容(消费仓既有配置不失效)。id 与 originId(原编号,
1045
+ * 2026-10-14-decision-legacy-renumber)双匹配——存量迁移后旧号键继续命中。
1046
+ * 无 decisions. 前缀的键不参与。导出(测试面先例 anchorFilePath)。 */
1047
+ export function isDecisionExempt(knownFailures, entryId, kind, domain = null, originId = null) {
1048
+ const ids = originId ? [entryId, originId] : [entryId]
1049
+ const keys = []
1050
+ for (const id of ids) {
1051
+ keys.push(`decisions.${id}.${kind}`, `decisions.${id}`)
1052
+ if (domain) keys.push(`decisions.${domain}/${id}.${kind}`, `decisions.${domain}/${id}`)
1053
+ }
1054
+ return knownFailures.some(k => keys.includes(k))
1047
1055
  }
1048
1056
 
1049
1057
  /**
@@ -1051,25 +1059,32 @@ function isDecisionExempt(knownFailures, entryId, kind) {
1051
1059
  * 的写入契约对偶:producer=decision-distill → consumer=本规则族;字段行非列表项,顶层
1052
1060
  * 「状态:/锚点:/最近确认:」标签精确匹配,防误吞正文)。旧格式字段缺失容错为 null。
1053
1061
  * @param {string} md 域文件全文
1054
- * @returns {Array<{ id: string, title: string, status: string|null, anchor: string|null, lastConfirmed: string|null }>}
1062
+ * @returns {Array<{ id: string, title: string, status: string|null, anchor: string|null, lastConfirmed: string|null, change: string|null }>}
1055
1063
  */
1056
1064
  export function parseDecisionEntries(md) {
1057
1065
  const entries = []
1058
1066
  let cur = null
1059
1067
  const flush = () => { if (cur) entries.push(cur) }
1060
1068
  for (const line of String(md || '').replace(/\r\n?/g, '\n').split('\n')) {
1061
- const h = line.match(/^##\s+(D-\d+)(?:@v(\d+))?\s*(.*)$/)
1069
+ const h = line.match(/^##\s+(D-(?:[A-Za-z0-9_\-\u4e00-\u9fff]+-)?\d+)(?:@v(\d+))?\s*(.*)$/)
1062
1070
  if (h) {
1063
1071
  flush()
1064
1072
  const version = h[2] ? parseInt(h[2], 10) : 1
1065
- cur = { id: `${h[1]}@v${version}`, title: h[3].trim(), status: null, anchor: null, lastConfirmed: null }
1073
+ cur = { id: `${h[1]}@v${version}`, title: h[3].trim(), status: null, anchor: null, lastConfirmed: null, change: null, originId: null }
1066
1074
  continue
1067
1075
  }
1068
1076
  if (!cur) continue
1069
- const f = line.match(/^(状态|锚点|最近确认)\s*[::]\s*(.*)$/)
1077
+ // 变更/来源/原编号:标签读入(变更:2026-10-14-supersedes-mislink-and-change-display 加法字段——
1078
+ // docs-debt 触碰事实行渲染变更名限定词消歧需读入;来源:2026-10-14-decision-id-p2-hardening legacy
1079
+ // 兜底——变更行无条件覆盖在前、来源仅补缺;原编号:2026-10-14-decision-legacy-renumber——存量迁移
1080
+ // 后条目 id 为全局号,消费仓旧豁免键(decisions.D-002@vN)经原编号继续命中;旧条目缺行容错 null)
1081
+ const f = line.match(/^(状态|锚点|最近确认|变更|来源|原编号)\s*[::]\s*(.*)$/)
1070
1082
  if (!f) continue
1071
1083
  if (f[1] === '状态') cur.status = f[2].trim().split(/[\s((]/)[0] || null
1072
1084
  else if (f[1] === '锚点') cur.anchor = f[2].trim()
1085
+ else if (f[1] === '变更') cur.change = f[2].trim() || null
1086
+ else if (f[1] === '来源') cur.change = cur.change || (f[2].trim() || null)
1087
+ else if (f[1] === '原编号') cur.originId = f[2].trim() || null
1073
1088
  else cur.lastConfirmed = f[2].trim()
1074
1089
  }
1075
1090
  flush()
@@ -1148,8 +1163,8 @@ export async function runDecisionRules(opts) {
1148
1163
  let implementedTotal = 0
1149
1164
  const treeCache = new Map() // resolveCandidates 全树扫描复用(同 docs check 主链路口径)
1150
1165
 
1151
- const emit = (id, domain, file, kind, extra, message) => {
1152
- if (isDecisionExempt(failures, id, kind)) {
1166
+ const emit = (id, domain, file, kind, extra, message, originId = null) => {
1167
+ if (isDecisionExempt(failures, id, kind, domain, originId)) {
1153
1168
  exempted.push({ key: `decisions.${id}.${kind}`, id, kind })
1154
1169
  return
1155
1170
  }
@@ -1169,10 +1184,10 @@ export async function runDecisionRules(opts) {
1169
1184
  // 规则 1:锚点存在性(未记录 → 补录提示;路径不存在 → 失效提示)
1170
1185
  if (!anchorPath) {
1171
1186
  emit(e.id, domain, rel, 'anchor', { anchor: e.anchor || null },
1172
- `「${e.id}」锚点未记录(${rel})——旧格式条目,补「锚点:src/…」后可机械校验(advisory)`)
1187
+ `「${e.id}」锚点未记录(${rel})——旧格式条目,补「锚点:src/…」后可机械校验(advisory)`, e.originId)
1173
1188
  } else if (resolveCandidates(projectRoot, anchorPath, treeCache).length === 0) {
1174
1189
  emit(e.id, domain, rel, 'anchor', { anchor: e.anchor },
1175
- `「${e.id}」锚点文件不存在:${anchorPath}(${rel})——决策落点已漂移,人工复核(advisory)`)
1190
+ `「${e.id}」锚点文件不存在:${anchorPath}(${rel})——决策落点已漂移,人工复核(advisory)`, e.originId)
1176
1191
  }
1177
1192
  // 规则 2:behind 复核(「最近确认」后锚定模块源码前进数超阈值 → 决策待复核;
1178
1193
  // 「最近确认」未记录/域无源码集(无 map 无锚点)→ 无法计算,静默跳过——advisory 不产噪声)
@@ -1185,7 +1200,7 @@ export async function runDecisionRules(opts) {
1185
1200
  const { behind } = computeModuleBehind(domain, e.lastConfirmed, { projectRoot, srcPaths })
1186
1201
  if (behind !== null && behind > threshold) {
1187
1202
  emit(e.id, domain, rel, 'behind', { behind, threshold },
1188
- `「${e.id}」决策待复核:锚定模块 ${domain} 在最近确认 ${e.lastConfirmed} 后源码已前进 ${behind} commit,超阈值 ${threshold}(${rel})——复核后更新「最近确认」`)
1203
+ `「${e.id}」决策待复核:锚定模块 ${domain} 在最近确认 ${e.lastConfirmed} 后源码已前进 ${behind} commit,超阈值 ${threshold}(${rel})——复核后更新「最近确认」`, e.originId)
1189
1204
  }
1190
1205
  }
1191
1206
  }
package/src/docs-debt.js CHANGED
@@ -15,6 +15,7 @@ import { join, relative } from 'node:path'
15
15
  import { safeGit } from './git-helper.js'
16
16
  import { parseModuleMapSimple } from './modules.js'
17
17
  import { runDocsCheck, readDocsCheckConfig, parseDecisionEntries, anchorFilePath } from './docs-check.js'
18
+ import { decisionChangeQualifier } from './knowledge-match.js'
18
19
 
19
20
  /** safeGit 超时(大仓 git log 慢时降级该模块,不挂起 Wave 渲染) */
20
21
  const GIT_TIMEOUT_MS = 5000
@@ -323,7 +324,7 @@ export function computeDecisionTouches(changedFiles, knowledgeRoot) {
323
324
  for (const c of changed) {
324
325
  // 前缀匹配粒度 = 文件或其子路径(R-03:锚点是文件级非目录级,防同前缀不同文件误报)
325
326
  if (c === anchorPath || c.startsWith(anchorPath + '/')) {
326
- touches.push({ id: e.id, title: e.title, anchorFile: anchorPath, touchedFile: c, file })
327
+ touches.push({ id: e.id, title: e.title, anchorFile: anchorPath, touchedFile: c, file, change: e.change || '' })
327
328
  }
328
329
  }
329
330
  }
@@ -344,7 +345,7 @@ export function renderDecisionTouchFacts(touches) {
344
345
  const shown = touches.slice(0, DECISION_TOUCH_RENDER_LIMIT)
345
346
  const lines = [`[decision-touch] 本次变更触碰 ${touches.length} 条决策锚点(改动可能使该决策需复核):`]
346
347
  for (const t of shown) {
347
- lines.push(` - ${t.id}(${t.title || '无标题'},锚点 ${t.anchorFile})← 触碰文件 ${t.touchedFile}(${t.file})`)
348
+ lines.push(` - ${t.id}(${t.title || '无标题'},锚点 ${t.anchorFile})← 触碰文件 ${t.touchedFile}(${t.file}|${decisionChangeQualifier(t.change)})`)
348
349
  }
349
350
  if (touches.length > DECISION_TOUCH_RENDER_LIMIT) {
350
351
  lines.push(` …另有 ${touches.length - DECISION_TOUCH_RENDER_LIMIT} 条`)
@@ -946,7 +946,9 @@ function detectDecisionDrift(cwd) {
946
946
  let count = 0
947
947
  for (const f of readdirSync(decDir)) {
948
948
  if (!f.endsWith('.md')) continue
949
- count += (readFileSync(join(decDir, f), 'utf8').match(/^## D-\d+@\d+/gm) || []).length
949
+ // 双形态计数(2026-10-14-decision-global-id-epoch):局部 D-011@v1 与全局 D-<域>-042@v1;
950
+ // 旧正则 @\d+ 漏 @v 前缀恒零——随本单修正
951
+ count += (readFileSync(join(decDir, f), 'utf8').match(/^## D-(?:[A-Za-z0-9_\-\u4e00-\u9fff]+-)?\d+@v\d+/gm) || []).length
950
952
  }
951
953
  return { ...base, findings: [`decisions 库 ${count} 条(behind 精确复核在 verify evidence-auto 与 docs-check 决策规则族)`], pass: true, severity: CHECK_SEVERITY.PASSED }
952
954
  } catch (e) {
package/src/flow-draft.js CHANGED
@@ -156,6 +156,11 @@ export function extractSuccessCriteria(input, opts = {}) {
156
156
  const lines = []
157
157
  for (const l of rawLines) {
158
158
  const prev = lines[lines.length - 1]
159
+ // 节标题合并守卫(2026-10-13-extract-head-guard 实证:动机行尾未闭合左括号 → 续行合并把
160
+ // 「成功标准:」并入动机、合并行以冒号收尾继续触发合并雪崩吞并全部条目行、提取 0 条。
161
+ // 节标题是结构边界——即使 prev 残缺也无条件独立成行。守卫只判当前行属性,不改
162
+ // needsContinuationMerge 本体(其括号判定是 R17 括号跨行并回的承重面)。
163
+ if (SECTION_HEAD_RE.test(l)) { lines.push(l); continue }
159
164
  if (prev != null && needsContinuationMerge(prev)) lines[lines.length - 1] = prev + l
160
165
  else lines.push(l)
161
166
  }
@@ -342,12 +347,29 @@ export function verifyThinDocsV2({ changeDir, ledger, skipDesign = false }) {
342
347
  let dLines = dText.split('\n')
343
348
  // 回填通道:改写先到不了归档(回填后进归档的就是锚文本,与人工恢复等价),写盘失败降级
344
349
  // 回带全文的拒收文案(不抛异常阻断 CLI)。
350
+ // 问句在场判定(坑 flow-done-design-question-line-injection,2026-10-13 实证):回答行
351
+ // 前缀形态「N. <问句>:<答>」/「- <问句>:…」——问句锚保留在行内时视为在场,不注入
352
+ // 不拒收。此前只认整行相等:锚折进回答行 → 相似度低于阈值 → 四条全走节首兜底插入,
353
+ // 同位反复 splice 产出倒序重复问句块。改写形态(行内无锚原文)仍走既有回填/拒收链。
354
+ const questionPresent = (lines, q) => lines.some((raw) => {
355
+ const t = String(raw || '').trim()
356
+ if (!t || t === q) return t === q
357
+ // 包含判定只对自带编号的锚生效(四问节「N. 问句」形态——回答行「N. 问句:答」折叠
358
+ // 是惯例书写面,注入即倒序重复块,坑 flow-done-design-question-line-injection)。
359
+ // 无编号锚(做法/契约/风险节单行问句)不做包含判定:⑦c 契约要求引用原文的作答行
360
+ // 仍注入锚在引用行上方(锚独立成行是审计面)——包含跳过会吃掉该注入。
361
+ if (!/^\d+[.、)]\s/.test(q)) return false
362
+ // 只剥一层编号/列表前缀(锚自带「N.」编号,多层剥会把锚的编号也吃掉致包含
363
+ // 判定假阴性——① 用例实证)
364
+ const stripped = t.replace(/^(?:\d+[.、)]\s*|[-*•]\s*)/, '')
365
+ return stripped.includes(q)
366
+ })
345
367
  const allQuestionLines = new Set(anchorSections.flatMap((s) => s.lines))
346
368
  const repairedLines = [...dLines]
347
369
  const repairEntries = []
348
370
  for (const sec of anchorSections) {
349
371
  for (const q of sec.lines) {
350
- if (repairedLines.some((l) => l.trim() === q)) continue
372
+ if (questionPresent(repairedLines, q)) continue
351
373
  const hIdx = repairedLines.findIndex((l) => l.trim() === `## ${sec.heading}`)
352
374
  if (hIdx === -1) continue // 节整删 → 下方校验环拒收(文案带全文)
353
375
  let end = repairedLines.findIndex((l, i) => i > hIdx && /^##\s/.test(l))
@@ -383,7 +405,7 @@ export function verifyThinDocsV2({ changeDir, ledger, skipDesign = false }) {
383
405
  continue
384
406
  }
385
407
  for (const q of sec.lines) {
386
- if (!dLines.some((l) => l.trim() === q)) violations.push(`design「${sec.heading}」问题文本被改写——原文:${q}(问题钉死,答案写在下方)`)
408
+ if (!questionPresent(dLines, q)) violations.push(`design「${sec.heading}」问题文本被改写——原文:${q}(问题钉死,答案写在下方;折进回答行前缀亦算在场)`)
387
409
  }
388
410
  // 实质作答:节内非空行 - 标题行 - 问题行 - 引导行(> 开头)≥1
389
411
  const start = dLines.findIndex((l) => l.trim() === `## ${sec.heading}`)
@@ -26,10 +26,12 @@ import { splitOwnVsForeignDiffFiles, commitAttributionForChange, isForeignByComm
26
26
  import { collectModuleMaps, normalizeMapPath } from './module-resolve.js'
27
27
  import { computeDocsDebt } from './docs-debt.js'
28
28
  import { writeAtomicSync } from './fs-atomic.js'
29
+ import { normalizePath } from './change-list.js'
29
30
  import { readV2SectionAnswer } from './flow-review.js'
30
31
  import { DESIGN_QUESTIONS } from './flow-draft.js'
31
32
  import { pathMatches } from './change-list.js'
32
33
  import { classifyToolScaffold, filterDeliverableFiles } from './worktree-apply.js'
34
+ import { deriveChangeTitle } from './quicklog.js'
33
35
 
34
36
  /**
35
37
  * 模块文档对账(advisory)+ 结构化落盘面(2026-09-27-thin-module-scope-persist)。
@@ -132,6 +134,19 @@ export function evaluateModuleGate({ moduleScope, ignore } = {}) {
132
134
  return { block: maps.length > 0 && dirs.length > 0, dirs, maps }
133
135
  }
134
136
 
137
+ /**
138
+ * ⑥-0 motiveFromInput:模块变更索引摘要提取(2026-10-15-title-label-skip)。与变更标题
139
+ * 同源走 deriveChangeTitle——此前 flow.js 裸取 --input 首行,「动机与背景:」段头行整行
140
+ * 入摘要(stages.changelog 实证)。段头行跳过/标签剥离随 deriveChangeTitle 单源;提取为
141
+ * 空回退变更名(可辨识不达意优于空串;appendChangeIndexEntries 侧另有 '轻量变更' 二级兜底)。
142
+ * @returns {string} 摘要(≤50 字)
143
+ */
144
+ export function motiveFromInput(input, change = '') {
145
+ let derived = ''
146
+ try { derived = deriveChangeTitle(input) } catch { derived = '' }
147
+ return derived || String(change || '')
148
+ }
149
+
135
150
  /**
136
151
  * ⑥ appendChangeIndexEntries:命中模块变更索引机械回写(二档)。零语义判断——日期/变更名/
137
152
  * 动机首行全是已知量,与 decision-distill 降 noAI 同性质。目标判定遵守 split-changelog 契约
@@ -308,7 +323,9 @@ export function harvestSlot4Decision({ changeDir, change }) {
308
323
  '---',
309
324
  `# 决策记录(Decisions)— ${change}`,
310
325
  '',
311
- `## D-001@v1: 风险与死路(design 槽4 收割)`,
326
+ // 标题自解释(2026-10-15-harvest-title-clarify):读者面是 decisions 文档与 knowledge,
327
+ // 机制出处(槽4 收割)由 frontmatter author: flow-machine-draft 承载,标题只说内容与性质。
328
+ `## D-001@v1: 设计期风险与放弃方案(底稿)`,
312
329
  `- 类型:process`,
313
330
  `- 状态:confirmed`,
314
331
  // 类型/状态字段(2026-09-26-slot4-distill-fix):蒸馏链入选要 status∈{confirmed|accepted|rejected}
@@ -325,7 +342,7 @@ export function harvestSlot4Decision({ changeDir, change }) {
325
342
  return { harvested: true }
326
343
  }
327
344
 
328
- export default { reconcileModuleDocs, renderVerifyReceipt, harvestSlot4Decision, collectFreezeFiles, backfillGateSummary }
345
+ export default { reconcileModuleDocs, renderVerifyReceipt, harvestSlot4Decision, collectFreezeFiles, backfillGateSummary, motiveFromInput }
329
346
 
330
347
  /**
331
348
  * patch 冻结面的提交面过滤(2026-09-27-tool-debt-cleanup 抽出为纯函数供单测锁定):
@@ -563,6 +580,19 @@ export function buildThinSnapshotRows({ ownFiles, stats, planEntries, crossRepoR
563
580
  // + pathMatches 双判——对账行是真实三态或 degraded ⊘,覆盖即不再补恒 ⊘ 行);对账行缺失
564
581
  // (fail-soft 退路/降级仓无行)→ 照旧补 ⊘ 行
565
582
  if (e.repo && crossRows.some((r) => r.crossRepo === e.repo && pathMatches(String(r.path || ''), e.path))) continue
583
+ // 兜底合并(2026-10-13-declared-paren-strip):declared 路径剥括号残片(normalizePath,
584
+ // 与解析端同源)后与实际行精确等值 → 不补占位行;实际行归因升 planned(取更准一行),
585
+ // totals 按唯一路径计。防他源污染形态致同文件双行/文件数虚高(缺陷档案实证形态的兜底层)。
586
+ const clean = normalizePath(String(e.path || ''))
587
+ const hitRow = clean ? rows.find((r) => r.path === clean && !r.crossRepo) : null
588
+ if (hitRow) {
589
+ matched.add(e.path)
590
+ if (hitRow.verdict !== 'planned') {
591
+ hitRow.planned = e.operation || null
592
+ hitRow.verdict = 'planned'
593
+ }
594
+ continue
595
+ }
566
596
  rows.push({
567
597
  path: e.path, planned: e.operation || null, additions: 0, deletions: 0,
568
598
  kind: 'modified', verdict: 'untouched',