sillyspec 3.32.9 → 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.
- package/.claude/skills/sillyspec-brainstorm/SKILL.md +2 -2
- package/.claude/skills/sillyspec-execute/SKILL.md +2 -2
- package/.claude/skills/sillyspec-flow/SKILL.md +1 -1
- package/.claude/skills/sillyspec-plan/SKILL.md +2 -2
- package/AGENTS.md +1 -1
- package/assets/command-cards/flow.md +1 -1
- package/package.json +1 -1
- package/src/commit-suggest.js +45 -4
- package/src/config-schema.js +14 -0
- package/src/decision-distill.js +77 -21
- package/src/decision-renumber.js +157 -0
- package/src/docs-check.js +30 -15
- package/src/docs-debt.js +3 -2
- package/src/doctor-diagnostics.js +3 -1
- package/src/flow-parity.js +18 -2
- package/src/flow.js +75 -12
- package/src/fr-index.js +15 -1
- package/src/hooks/worktree-guard.js +3 -3
- package/src/index.js +7 -5
- package/src/knowledge-digest.js +23 -6
- package/src/knowledge-graph.js +92 -14
- package/src/knowledge-match.js +34 -9
- package/src/knowledge-stats.js +12 -13
- package/src/knowledge-vector.js +98 -7
- package/src/machine-interface.js +26 -0
- package/src/quicklog.js +6 -1
- package/src/run/archive-distill.js +3 -0
- package/src/run/command.js +3 -3
- package/src/run/complete-handlers.js +8 -3
- package/src/run/complete.js +113 -44
- package/src/run/gates.js +93 -89
- package/src/run/prompt.js +3 -3
- package/src/run/stage.js +1 -1
- package/src/sentinel-assertions.js +43 -1
- package/src/stage-contract.js +26 -3
- package/src/stage-review.js +44 -11
- package/src/stage-templates.js +2 -0
- package/src/stages/brainstorm.js +5 -1
- package/src/stages/knowledge.js +56 -5
- package/src/sync.js +28 -2
- package/src/task-tick.js +12 -0
- package/src/timeline.js +60 -19
- package/src/verify-postcheck.js +49 -1
- package/src/verify-probes.js +61 -18
- package/templates/agents-instruction.md +1 -1
|
@@ -80,8 +80,8 @@ brainstorm 完成时按 design.md frontmatter 的 `scale` 分叉:
|
|
|
80
80
|
|
|
81
81
|
brainstorm 在 `tier=independent` 规模下除 design.md 等四件套外,还需产出一个 stage 级 `review.json`,CLI `Stage Review Gate` 硬校验其 schema 与 `docHash` 真实性。
|
|
82
82
|
|
|
83
|
-
- 路径:`.sillyspec/.runtime/stage-reviews/brainstorm
|
|
84
|
-
- **run-id / marker 由 CLI 自动生成注入**(review step prompt 渲染时 echo 完整目录路径 + 写 marker;撞 gate 报缺 review.json 时 gate 也 echo 完整路径 + 写 marker)。直接用 CLI 给的路径写 review.json,**勿手算 run-id
|
|
83
|
+
- 路径:`.sillyspec/.runtime/stage-reviews/brainstorm-<run-id>/review.json`——**目录名 = `brainstorm-` + marker 值原样**(run-id 本身已带 `review-` 前缀:marker 值 `review-2026-10-15-001431` → 目录 `brainstorm-review-2026-10-15-001431`。勿再拼一层 review-,也勿拿 marker 值当完整目录名)。目录可能不存在需手建;run-id 由该步 `--done` prompt 输出指定;marker 文件 `.runtime/current-stage-review-run-id-brainstorm-<变更名>`。
|
|
84
|
+
- **run-id / marker 由 CLI 自动生成注入**(review step prompt 渲染时 echo 完整目录路径 + 写 marker;撞 gate 报缺 review.json 时 gate 也 echo 完整路径 + 写 marker)。直接用 CLI 给的路径写 review.json,**勿手算 run-id、勿手写 marker**。卡住时用 `sillyspec register-stage-review --change <名> --stage brainstorm` 一步生成。
|
|
85
85
|
- 字段(`schemaVersion:1`,`reviewType=design` —— 区别于 execute 的 `acceptance`):
|
|
86
86
|
|
|
87
87
|
```json
|
|
@@ -106,8 +106,8 @@ execute 完成时,每个 task 必须有 `review.json` 且 verdict 通过,否
|
|
|
106
106
|
|
|
107
107
|
execute 还有**第二道**独立的 stage 级审查:除逐 task review.json 外,整个 execute 阶段完成还需一个 stage 级 `review.json`(在 "完成确认"/acceptance 步骤产出)。CLI `Stage Review Gate` 硬校验其 schema 与 `docHash` 真实性。
|
|
108
108
|
|
|
109
|
-
- 路径:`.sillyspec/.runtime/stage-reviews/execute
|
|
110
|
-
- **run-id / marker 由 CLI 自动生成注入**(review step prompt 渲染时 echo 完整目录路径 + 写 marker;撞 gate 报缺 review.json 时 gate 也 echo 完整路径 + 写 marker)。直接用 CLI 给的路径写 review.json,**勿手算 run-id
|
|
109
|
+
- 路径:`.sillyspec/.runtime/stage-reviews/execute-<run-id>/review.json`——**目录名 = `execute-` + marker 值原样**(run-id 本身已带 `review-` 前缀:marker 值 `review-2026-10-15-001431` → 目录 `execute-review-2026-10-15-001431`。勿再拼一层 review-,也勿拿 marker 值当完整目录名)。目录可能不存在需手建;run-id 由该步 `--done` prompt 输出指定;marker 文件 `.runtime/current-stage-review-run-id-execute-<变更名>`。
|
|
110
|
+
- **run-id / marker 由 CLI 自动生成注入**(review step prompt 渲染时 echo 完整目录路径 + 写 marker;撞 gate 报缺 review.json 时 gate 也 echo 完整路径 + 写 marker)。直接用 CLI 给的路径写 review.json,**勿手算 run-id、勿手写 marker**。卡住时用 `sillyspec register-stage-review --change <名> --stage execute` 一步生成。
|
|
111
111
|
- 字段(`schemaVersion:1`,`reviewType=acceptance` —— 区别于 brainstorm/plan 的 `"design"`):
|
|
112
112
|
|
|
113
113
|
```json
|
|
@@ -49,8 +49,8 @@ plan 的步骤是动态的:`generate_plan`(生成分级计划)→ `review_
|
|
|
49
49
|
|
|
50
50
|
`review_plan` 步骤在 `tier=independent` 时产出一个 stage 级 `review.json`,CLI `Stage Review Gate` 硬校验其 schema 与 `docHash` 真实性。
|
|
51
51
|
|
|
52
|
-
- 路径:`.sillyspec/.runtime/stage-reviews/plan
|
|
53
|
-
- **run-id / marker 由 CLI 自动生成注入**(review step prompt 渲染时 echo 完整目录路径 + 写 marker;撞 gate 报缺 review.json 时 gate 也 echo 完整路径 + 写 marker)。直接用 CLI 给的路径写 review.json,**勿手算 run-id
|
|
52
|
+
- 路径:`.sillyspec/.runtime/stage-reviews/plan-<run-id>/review.json`——**目录名 = `plan-` + marker 值原样**(run-id 本身已带 `review-` 前缀:marker 值 `review-2026-10-15-001431` → 目录 `plan-review-2026-10-15-001431`。勿再拼一层 review-,也勿拿 marker 值当完整目录名)。目录可能不存在需手建;run-id 由该步 `--done` prompt 输出指定;marker 文件 `.runtime/current-stage-review-run-id-plan-<变更名>`。
|
|
53
|
+
- **run-id / marker 由 CLI 自动生成注入**(review step prompt 渲染时 echo 完整目录路径 + 写 marker;撞 gate 报缺 review.json 时 gate 也 echo 完整路径 + 写 marker)。直接用 CLI 给的路径写 review.json,**勿手算 run-id、勿手写 marker**。卡住时用 `sillyspec register-stage-review --change <名> --stage plan` 一步生成。
|
|
54
54
|
- 字段(`schemaVersion:1`,`reviewType=plan` —— plan 阶段主审查文档是 `plan.md`;execute 阶段才是 `acceptance`):
|
|
55
55
|
|
|
56
56
|
```json
|
package/AGENTS.md
CHANGED
|
@@ -11,7 +11,7 @@ description: 轻量变更(默认快道):需求明确的小/中改动两步
|
|
|
11
11
|
```bash
|
|
12
12
|
export SILLYSPEC_SESSION_ID=<agent名+任务名> # 每条命令都带(shell 状态不持久;多会话并行的所有权判定依赖它)
|
|
13
13
|
# ① 立项 + 锁基线(--input 引号内换行合法,可照抄;缺「成功标准」条目会被清晰度门拦下):
|
|
14
|
-
sillyspec flow start --change <变更名> --input "
|
|
14
|
+
sillyspec flow start --change <变更名> --input "<一句中文概括(首行即变更标题);随后动机与背景
|
|
15
15
|
|
|
16
16
|
成功标准:
|
|
17
17
|
- <可验证标准>"
|
package/package.json
CHANGED
package/src/commit-suggest.js
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* C. 阶段产出:changed paths 按路径模式归 scan/brainstorm/plan/execute/archive/knowledge
|
|
18
18
|
*/
|
|
19
19
|
import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
|
|
20
|
-
import { join } from 'path'
|
|
20
|
+
import { join, relative } from 'path'
|
|
21
21
|
import { DatabaseSync } from 'node:sqlite'
|
|
22
22
|
import { gitQuiet, safeGit, stageArchiveArtifacts } from './git-helper.js'
|
|
23
23
|
import { resolveSpecDir } from './run/shared.js'
|
|
@@ -117,6 +117,12 @@ export function collectCommitContext({ cwd, specDir = null }) {
|
|
|
117
117
|
const stat = gitQuiet(cwd, ['diff', 'HEAD', '--stat']) || (changedPaths.length > 0 ? `(${changedPaths.length} 个文件待首提交)` : '')
|
|
118
118
|
|
|
119
119
|
// ── 来源 A:QUICKLOG(主 quicklog + 各活跃变更归属 quicklog)──
|
|
120
|
+
// 并行会话噪声锚定(坑 probe-noise-and-commit-suggest-quality 坑B,2026-10-15 消费仓实证:
|
|
121
|
+
// 归档提交时他并行 quick 会话的 QUICKLOG 新条目(时间窗过滤粗筛通过)把 feat 建议劫持成
|
|
122
|
+
// 「fix: quick 修复 + 阶段产出」——type 误判 + 凭空 quick 字样。quick 来源现加锚「quicklog
|
|
123
|
+
// 文件属于本次提交变更集」(porcelain 含 staged/unstaged/untracked):不在变更集的 quicklog
|
|
124
|
+
// 条目不参与建议(时间窗保留作首道粗筛,零条目时无建议行为不变)。
|
|
125
|
+
const changedSet = new Set(changedPaths.map(p => String(p).replace(/\\/g, '/')))
|
|
120
126
|
const quickFiles = []
|
|
121
127
|
try {
|
|
122
128
|
for (const f of readdirSync(join(specBase, 'quicklog'))) {
|
|
@@ -133,6 +139,7 @@ export function collectCommitContext({ cwd, specDir = null }) {
|
|
|
133
139
|
const quickEntries = quickFiles
|
|
134
140
|
.flatMap(file => parseQuicklogEntries(file).map(e => ({ ...e, file })))
|
|
135
141
|
.filter(e => !lastCommitKey || e.time > lastCommitKey)
|
|
142
|
+
.filter(e => changedSet.has(relative(cwd, e.file).replace(/\\/g, '/')))
|
|
136
143
|
.sort((a, b) => (a.time < b.time ? -1 : 1))
|
|
137
144
|
const doneQuick = quickEntries.filter(e => e.status === '已完成')
|
|
138
145
|
|
|
@@ -155,6 +162,27 @@ export function collectCommitContext({ cwd, specDir = null }) {
|
|
|
155
162
|
}
|
|
156
163
|
}
|
|
157
164
|
|
|
165
|
+
// ── 来源 B2:归档产物任务面(坑 probe-noise-and-commit-suggest-quality 坑B 连带)──
|
|
166
|
+
// apply 后 tasks.md 移到 changes/archive/<变更>/——来源 B 的 mtime 锚对已提交面恒失效
|
|
167
|
+
// (任务勾选随 execute 提交走,mtime 早于上次 commit → hasTasks 恒 false → 归档提交被判
|
|
168
|
+
// fix)。归档目录下 tasks.md 在本次变更集时解析其 [x] 行进 task 面(归档提交的 feat 语义正源)。
|
|
169
|
+
try {
|
|
170
|
+
const archDir = join(specBase, 'changes', 'archive')
|
|
171
|
+
if (existsSync(archDir)) {
|
|
172
|
+
for (const c of readdirSync(archDir)) {
|
|
173
|
+
const tp = join(archDir, c, 'tasks.md')
|
|
174
|
+
try {
|
|
175
|
+
if (!statSync(tp).isFile()) continue
|
|
176
|
+
} catch { continue }
|
|
177
|
+
if (!changedSet.has(relative(cwd, tp).replace(/\\/g, '/'))) continue
|
|
178
|
+
for (const line of readFileSync(tp, 'utf8').split('\n')) {
|
|
179
|
+
const m = line.match(/^[-*]\s*\[[xX]\]\s*(task-\d+)\s*[::]?\s*(.*)/)
|
|
180
|
+
if (m) checkedTasks.push({ change: c, item: `${m[1]}: ${m[2].trim()}` })
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
} catch { /* 归档扫描失败静默(来源面缺省,行为回退修复前) */ }
|
|
185
|
+
|
|
158
186
|
// ── 来源 C:阶段产出(changed paths 按模式归类去重)──
|
|
159
187
|
const stageSet = new Set()
|
|
160
188
|
for (const p of changedPaths) {
|
|
@@ -195,11 +223,24 @@ export function collectCommitContext({ cwd, specDir = null }) {
|
|
|
195
223
|
type = 'feat'
|
|
196
224
|
subject = `feat: 变更交付 + quick 修复 ${doneQuick.length} 项`
|
|
197
225
|
} else {
|
|
198
|
-
|
|
199
|
-
|
|
226
|
+
// 纯阶段产出(坑 probe-noise-and-commit-suggest-quality 坑B,2026-10-15 实证:原
|
|
227
|
+
// 「fix: quick 修复 + 阶段产出」在无任何 quick 证据时凭空捏造 quick 语义且误判 fix——
|
|
228
|
+
// 无 task 无 quick 的归档/知识面提交按 chore 归档语义建议)
|
|
229
|
+
type = 'chore'
|
|
230
|
+
subject = `chore: 阶段产出归档(${stageArtifacts.join('、')})`
|
|
200
231
|
}
|
|
201
232
|
if (doneQuick.length >= 2 || hasTasks || hasStages) {
|
|
202
|
-
|
|
233
|
+
// body 与 subject 去重(坑同上:quick ≤2 条时标题已全列 subject,body 原样重复无信息量)
|
|
234
|
+
const subjectListed = doneQuick.length <= 2 ? new Set(doneQuick.map(q => q.title)) : null
|
|
235
|
+
for (const q of doneQuick) {
|
|
236
|
+
// 标题已列 subject 时 body 仍保留 qlId 行(去重不丢审计锚——标题可省,qlId 是
|
|
237
|
+
// QUICKLOG 对账锚点,既有测试契约钉住 body 含 qlId)
|
|
238
|
+
if (subjectListed && subjectListed.has(q.title)) {
|
|
239
|
+
bodyLines.push(`- quick ${q.qlId}(标题见上)`)
|
|
240
|
+
continue
|
|
241
|
+
}
|
|
242
|
+
bodyLines.push(`- quick ${q.qlId}: ${q.title}`)
|
|
243
|
+
}
|
|
203
244
|
}
|
|
204
245
|
for (const t of checkedTasks) bodyLines.push(`- ${t.change} ${t.item}`)
|
|
205
246
|
for (const s of stageArtifacts) bodyLines.push(`- 阶段产出: ${s}`)
|
package/src/config-schema.js
CHANGED
|
@@ -77,6 +77,14 @@ export const LOCAL_YAML_SCHEMA = {
|
|
|
77
77
|
{ path: 'commands.knowledge-gate', type: 'boolean', optional: true, status: 'live', readers: ['completeStep (src/run/complete.js)'], desc: '【2026-09-28-unclear-req-to-brainstorm FR-03/D-004】brainstorm 方案步(提出 2-3 种方案)--done 知识检索命中回显开关——缺省 true=开(对 --output 与 decisions.md 条目跑既有检索匹配器,命中 warn 回显「须 evidence 回应或说明不复潮」不阻断);false(或 off)关回显,无命中时输出与现状一致。', example: 'false' },
|
|
78
78
|
],
|
|
79
79
|
},
|
|
80
|
+
{
|
|
81
|
+
id: 'knowledge',
|
|
82
|
+
title: '知识检索(平台向量语义召回)',
|
|
83
|
+
note: '可选段——缺省(未写整段/auto)跟随平台能力自动探测:端点在场自动启用、未部署缓存 1h 内跳过(部署后自动启用,零配置;2026-10-15-platform-vector-search)。off=显式逃生阀强制关,on=强制开跳过探测。能力缓存落 .runtime/vector-capability.json(gitignored);提示触点:knowledge search hint 键、flow 注入零命中行(auto 未就绪时信息性提示)。',
|
|
84
|
+
keys: [
|
|
85
|
+
{ path: 'knowledge.vector_search', type: 'string', optional: true, status: 'live', readers: ['vectorSwitchFromLocalYaml (src/knowledge-vector.js)'], desc: '【2026-10-15-vector-capability-autodetect 终态】平台向量语义召回开关——缺省(未写键/auto)跟随平台能力自动探测:端点在场自动启用、未部署缓存 1h 内跳过(部署后自动启用,零配置);off=显式逃生阀强制关、on=强制开跳过探测。env SILLYSPEC_KNOWLEDGE_VECTOR=on|auto/off 同语义强制。四层检索第二层:路由 tag 零命中后平台向量召回(语料=knowledge/decisions 条目,词面不相交条目可语义召回)。', example: 'off' },
|
|
86
|
+
],
|
|
87
|
+
},
|
|
80
88
|
{
|
|
81
89
|
id: 'mcp',
|
|
82
90
|
title: 'SillyHub MCP 客户端凭据(派发用)',
|
|
@@ -387,6 +395,12 @@ commands:
|
|
|
387
395
|
# lint_timeout_sec: 600 # commands.lint 超时秒数(缺省 180)
|
|
388
396
|
# knowledge-gate: false # 方案步 --done 知识检索命中回显开关(缺省 true 开;false 关回显)
|
|
389
397
|
|
|
398
|
+
# ── 知识检索(平台向量语义召回)──
|
|
399
|
+
# 缺省(未写整段)跟随平台能力自动探测:端点在场自动启用、未部署自动跳过(部署后自动
|
|
400
|
+
# 启用,零配置)。仅显式逃生阀时才需要写:
|
|
401
|
+
# knowledge:
|
|
402
|
+
# vector_search: off # 强制关(on=强制开跳过探测)
|
|
403
|
+
|
|
390
404
|
# ── SillyHub MCP 客户端凭据(派发到 worker 用)──
|
|
391
405
|
# agent 手填,或 sillyspec platform connect 在 mcp 段缺失时同源自动填。
|
|
392
406
|
# 两键齐全才生效;缺则回退环境变量 SILLYHUB_MCP_URL / SILLYHUB_MCP_TOKEN。
|
package/src/decision-distill.js
CHANGED
|
@@ -313,10 +313,13 @@ function newDomainPreamble(domain) {
|
|
|
313
313
|
* 变更:行(2026-08-28,坑 distill-cross-change-supersede):D-xxx 编号是变更内局部序号,
|
|
314
314
|
* 跨变更同号(两个 D-002)互不相干——条目必须携带变更名限定,消费方(knowledge-match/
|
|
315
315
|
* docs-check 的字段行解析)只认各自标签,新增字段行是增量安全。 */
|
|
316
|
-
function renderBlockLines(entry, headHash, supersedesNote, changeName) {
|
|
317
|
-
const lines = [`## ${entry.
|
|
316
|
+
function renderBlockLines(entry, headHash, supersedesNote, changeName, sectionNumber, originId) {
|
|
317
|
+
const lines = [`## ${sectionNumber}@v${entry.version} ${oneLine(entry.title)}`.replace(/\s+$/, '')]
|
|
318
318
|
lines.push(`状态:${entry.selected}`)
|
|
319
319
|
if (changeName) lines.push(`变更:${oneLine(changeName)}`)
|
|
320
|
+
// 原编号(2026-10-14-decision-global-id-epoch):epoch 形态溯源字段——变更行后/锚点行前
|
|
321
|
+
// (不破「文件:紧跟锚点」行序红线);legacy 复用段(originId=null)不渲染,形态零变化
|
|
322
|
+
if (originId) lines.push(`原编号:${oneLine(originId)}`)
|
|
320
323
|
lines.push(`锚点:${oneLine(entry.anchor) || NOT_RECORDED}`)
|
|
321
324
|
// 文件行(FR-01/D-001@v1):仅非空渲染——存量条目零迁移,幂等重归档不添空行
|
|
322
325
|
if (entry.files && entry.files.length > 0) lines.push(`文件:${entry.files.join(', ')}`)
|
|
@@ -336,12 +339,14 @@ function renderBlockLines(entry, headHash, supersedesNote, changeName) {
|
|
|
336
339
|
}
|
|
337
340
|
|
|
338
341
|
/** 知识文件切段:preamble 行 + 条目段(段边界 = 下一节头)。
|
|
339
|
-
* 段的变更归属取段内 `变更:<name>`
|
|
340
|
-
*
|
|
342
|
+
* 段的变更归属取段内 `变更:<name>` 行,「来源:<name>` 兜底(2026-10-14-decision-id-p2-hardening:
|
|
343
|
+
* 08-24 首修形态残留——读出后带来源的 legacy 段参与同变更同号匹配/supersede,重蒸馏在位更新收敛);
|
|
344
|
+
* 无任何限定行的历史条目 → change=null(legacy:升级前落库的条目,不参与新条目的同号匹配——只共存不误删)。
|
|
341
345
|
* 2026-09-18-fr-index-l1 参数化:sectionRegex/buildId 经参(decisions 缺省不变;fr 侧传 FR 节头
|
|
342
346
|
* 正则与恒等 id),decisions 调用点零改动。 */
|
|
343
347
|
export function splitKnowledgeSections(content, opts = {}) {
|
|
344
|
-
|
|
348
|
+
// 节头号双形态(2026-10-14-decision-global-id-epoch):局部 D-011@v1 与全局 D-<域>-042@v1 并存
|
|
349
|
+
const sectionRegex = opts.sectionRegex || /^## (D-(?:[A-Za-z0-9_\-\u4e00-\u9fff]+-)?\d+)@v(\d+)\s*(.*)$/
|
|
345
350
|
const buildId = opts.buildId || ((n, v) => `${n}@v${v}`)
|
|
346
351
|
const sections = []
|
|
347
352
|
const preamble = []
|
|
@@ -359,8 +364,15 @@ export function splitKnowledgeSections(content, opts = {}) {
|
|
|
359
364
|
if (cur) sections.push(cur)
|
|
360
365
|
for (const s of sections) {
|
|
361
366
|
s.id = buildId(s.number, s.version)
|
|
362
|
-
|
|
363
|
-
|
|
367
|
+
// 「变更:」优先、「来源:」兜底(行内前缀按命中行剥除;无任何限定行 → null 不互认)
|
|
368
|
+
const cm = s.lines.find(l => /^变更[::]/.test(l)) || s.lines.find(l => /^来源[::]/.test(l))
|
|
369
|
+
s.change = cm ? cm.replace(/^(变更|来源)[::]\s*/, '').trim() || null : null
|
|
370
|
+
// 原编号(2026-10-14-decision-global-id-epoch 溯源字段):「原编号:D-011@v1」→ origin/originNumber;
|
|
371
|
+
// legacy 段无该行 → null(幂等匹配谓词按号兜底)
|
|
372
|
+
const oi = s.lines.find(l => /^原编号[::]/.test(l))
|
|
373
|
+
s.origin = oi ? oi.replace(/^原编号[::]\s*/, '').trim() || null : null
|
|
374
|
+
const on = s.origin ? /^(D-(?:[A-Za-z0-9_\-\u4e00-\u9fff]+-)?\d+)@v?\d+/.exec(s.origin) : null
|
|
375
|
+
s.originNumber = on ? on[1] : null
|
|
364
376
|
}
|
|
365
377
|
return { preamble, sections }
|
|
366
378
|
}
|
|
@@ -385,14 +397,23 @@ export function joinKnowledgeFile(preamble, sections) {
|
|
|
385
397
|
* 升级前落库)不与任何新条目互认,只共存不误删。
|
|
386
398
|
* @returns {{ action: 'append'|'update'|'supersede' }|null} null = 同变更同号高版本已落库,迟到低版本不降级。
|
|
387
399
|
*/
|
|
388
|
-
|
|
400
|
+
/** 同变更同段匹配谓词(2026-10-14-decision-global-id-epoch 扩展):变更一致 且
|
|
401
|
+
* (段号==条目局部号 或 段.原编号号段==条目局部号)——legacy 段按号匹配、epoch 段按原编号匹配。
|
|
402
|
+
* @returns {{action: 'append'|'update'|'supersede'}|null} null = 同变更同段高版本已落库,迟到低版本不降级。 */
|
|
403
|
+
function sameDecisionNumber(s, entry) {
|
|
404
|
+
return (s.change || null) === (entry.change || null) && (s.number === entry.number || s.originNumber === entry.number)
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function applyEntryToSections(sections, entry, headHash, sectionNumber, originId) {
|
|
389
408
|
const change = entry.change || null
|
|
390
|
-
const sameNumber = sections.filter(s => s
|
|
409
|
+
const sameNumber = sections.filter(s => sameDecisionNumber(s, entry))
|
|
391
410
|
if (sameNumber.some(s => s.version > entry.version)) return null
|
|
392
411
|
let action
|
|
393
412
|
let supersedesNote = oneLine(entry.supersedes) || null
|
|
413
|
+
const sectionId = `${sectionNumber}@v${entry.version}`
|
|
414
|
+
const originNumber = originId ? (/^(D-(?:[A-Za-z0-9_\-\u4e00-\u9fff]+-)?\d+)/.exec(originId) || [])[1] || null : null
|
|
394
415
|
if (sameNumber.length === 0) {
|
|
395
|
-
sections.push({ number:
|
|
416
|
+
sections.push({ number: sectionNumber, version: entry.version, id: sectionId, title: entry.title, change, origin: originId, originNumber, lines: renderBlockLines(entry, headHash, supersedesNote, change || undefined, sectionNumber, originId) })
|
|
396
417
|
action = 'append'
|
|
397
418
|
} else {
|
|
398
419
|
const same = sameNumber.find(s => s.version === entry.version)
|
|
@@ -400,10 +421,14 @@ function applyEntryToSections(sections, entry, headHash) {
|
|
|
400
421
|
if (!same && !supersedesNote) supersedesNote = `${slot.number}@v${slot.version}`
|
|
401
422
|
slot.version = entry.version
|
|
402
423
|
slot.title = entry.title
|
|
403
|
-
slot.
|
|
404
|
-
|
|
424
|
+
slot.number = sectionNumber
|
|
425
|
+
slot.id = sectionId
|
|
426
|
+
slot.origin = originId
|
|
427
|
+
slot.originNumber = originNumber
|
|
428
|
+
slot.lines = renderBlockLines(entry, headHash, supersedesNote, change || undefined, sectionNumber, originId)
|
|
429
|
+
// 同变更同段(号或原编号匹配)旧版本就地清除——同文件内同变更同段只留一个段
|
|
405
430
|
for (let i = sections.length - 1; i >= 0; i--) {
|
|
406
|
-
if (sections[i]
|
|
431
|
+
if (sameDecisionNumber(sections[i], entry) && sections[i] !== slot) sections.splice(i, 1)
|
|
407
432
|
}
|
|
408
433
|
action = same ? 'update' : 'supersede'
|
|
409
434
|
}
|
|
@@ -577,31 +602,62 @@ export function distillIntoKnowledge(changeDir, knowledgeRoot, headHash, moduleI
|
|
|
577
602
|
}
|
|
578
603
|
|
|
579
604
|
const written = []
|
|
605
|
+
// 域全集(跨文件清理与复用扫描共用;磁盘 ∪ 本批已加载)
|
|
606
|
+
let onDisk = []
|
|
607
|
+
try { onDisk = readdirSync(decisionsDir).filter(f => f.endsWith('.md')).map(f => f.replace(/\.md$/, '')) } catch { /* 刚创建,走 store */ }
|
|
608
|
+
let allDomains = [...new Set([...onDisk, ...store.keys()])]
|
|
609
|
+
// 发号器(2026-10-14-decision-global-id-epoch):域前缀全域 max+1 只增不复用;mintMax 批内会话
|
|
610
|
+
// 计数防同批撞号(磁盘扫描只做初值);并发蒸馏撞号窗口与 FR 侧发号同病(接受,doctor 可见)。
|
|
611
|
+
const mintMax = new Map()
|
|
612
|
+
const mintFor = (domain) => {
|
|
613
|
+
if (!mintMax.has(domain)) {
|
|
614
|
+
let max = 0
|
|
615
|
+
for (const f of onDisk) {
|
|
616
|
+
let c; try { c = readFileSync(join(decisionsDir, f.endsWith('.md') ? f : `${f}.md`), 'utf8') } catch { continue }
|
|
617
|
+
const re = /^## D-([A-Za-z0-9_\-\u4e00-\u9fff]+?)-(\d+)@v\d+/gm
|
|
618
|
+
let m
|
|
619
|
+
while ((m = re.exec(c)) !== null) if (m[1] === domain) max = Math.max(max, parseInt(m[2], 10))
|
|
620
|
+
}
|
|
621
|
+
mintMax.set(domain, max)
|
|
622
|
+
}
|
|
623
|
+
const next = mintMax.get(domain) + 1
|
|
624
|
+
mintMax.set(domain, next)
|
|
625
|
+
return `D-${domain}-${String(next).padStart(3, '0')}`
|
|
626
|
+
}
|
|
580
627
|
const targetSets = new Map() // entry → Set(域)(跨文件清理时排除目标文件)
|
|
581
628
|
for (const entry of batch) {
|
|
582
629
|
const domains = resolveDomains(entry, idx)
|
|
583
630
|
targetSets.set(entry, new Set(domains))
|
|
631
|
+
// 号决策(2026-10-14-decision-global-id-epoch):同变更同段存量(全域文件扫描,号或原编号匹配)
|
|
632
|
+
// 复用其号——legacy 段保持局部形态(不补原编号不改前缀,存量零触碰);epoch 段原编号随版本更新;
|
|
633
|
+
// 无匹配 → 发号域=首域(resolveDomains 已 sanitize)批内 max+1,扇出多文件同一号(单一身份)。
|
|
634
|
+
let reuse = null
|
|
635
|
+
for (const d of allDomains) {
|
|
636
|
+
const st = loadDomain(d)
|
|
637
|
+
reuse = st.sections.find(s => sameDecisionNumber(s, entry)) || null
|
|
638
|
+
if (reuse) break
|
|
639
|
+
}
|
|
640
|
+
const sectionNumber = reuse ? reuse.number : mintFor(domains[0])
|
|
641
|
+
const originId = reuse && !reuse.origin ? null : `${entry.number}@v${entry.version}`
|
|
584
642
|
for (const d of domains) {
|
|
585
643
|
const st = loadDomain(d)
|
|
586
|
-
const res = applyEntryToSections(st.sections, entry, headHash)
|
|
644
|
+
const res = applyEntryToSections(st.sections, entry, headHash, sectionNumber, originId)
|
|
587
645
|
if (res) {
|
|
588
646
|
st.dirty = true
|
|
589
|
-
written.push({ file: `decisions/${d}.md`, id: entry.
|
|
647
|
+
written.push({ file: `decisions/${d}.md`, id: `${sectionNumber}@v${entry.version}`, action: res.action })
|
|
590
648
|
}
|
|
591
649
|
}
|
|
650
|
+
allDomains = [...new Set([...allDomains, ...domains])]
|
|
592
651
|
}
|
|
593
652
|
|
|
594
|
-
//
|
|
595
|
-
//
|
|
596
|
-
let onDisk = []
|
|
597
|
-
try { onDisk = readdirSync(decisionsDir).filter(f => f.endsWith('.md')).map(f => f.replace(/\.md$/, '')) } catch { /* 刚创建,走 store */ }
|
|
598
|
-
const allDomains = [...new Set([...onDisk, ...store.keys()])]
|
|
653
|
+
// 跨文件清理:同变更同段(号或原编号匹配)≤ 新版本的旧段散落其他域文件时移除(同变更同段
|
|
654
|
+
// 全局只留最高版本/目标文件);他者变更的同号段不参与(共存,坑 distill-cross-change-supersede)
|
|
599
655
|
for (const entry of batch) {
|
|
600
656
|
const targets = targetSets.get(entry)
|
|
601
657
|
for (const d of allDomains) {
|
|
602
658
|
if (targets.has(d)) continue
|
|
603
659
|
const st = loadDomain(d)
|
|
604
|
-
const removed = st.sections.filter(s => s.
|
|
660
|
+
const removed = st.sections.filter(s => s.version <= entry.version && sameDecisionNumber(s, entry))
|
|
605
661
|
if (removed.length === 0) continue
|
|
606
662
|
st.sections = st.sections.filter(s => !removed.includes(s))
|
|
607
663
|
st.dirty = true
|
|
@@ -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 }
|
package/src/docs-check.js
CHANGED
|
@@ -1038,12 +1038,20 @@ export function readDecisionRulesConfig(projectRoot) {
|
|
|
1038
1038
|
}
|
|
1039
1039
|
}
|
|
1040
1040
|
|
|
1041
|
-
/**
|
|
1042
|
-
*
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
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
|
-
|
|
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
|
}
|