sillyspec 3.32.9 → 3.33.0
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 +114 -114
- package/.claude/skills/sillyspec-execute/SKILL.md +222 -222
- package/.claude/skills/sillyspec-explore/SKILL.md +107 -107
- package/.claude/skills/sillyspec-flow/SKILL.md +1 -1
- package/.claude/skills/sillyspec-plan/SKILL.md +156 -156
- package/.github/workflows/ci.yml +58 -0
- package/.husky/pre-push +7 -6
- package/AGENTS.md +1 -1
- package/assets/command-cards/flow.md +1 -1
- package/eval/eval.config.json +3 -3
- package/eval/lib/config.mjs +6 -3
- package/eval/lib/util.mjs +16 -3
- package/eval/lib/workdir.mjs +4 -1
- package/eval/results/eval.db +0 -0
- package/eval/results/logs/2026-10-15T12-37-22-986Z-004-phase-stats-armA/prompt.txt +10 -0
- package/eval/results/logs/2026-10-15T12-37-22-986Z-004-phase-stats-armA/stderr.txt +0 -0
- package/eval/results/logs/2026-10-15T12-37-22-986Z-004-phase-stats-armA/stdout.txt +1 -0
- package/eval/results/logs/2026-10-15T12-45-37-124Z-004-phase-stats-armB/prompt.txt +10 -0
- package/eval/results/logs/2026-10-15T12-45-37-124Z-004-phase-stats-armB/stderr.txt +0 -0
- package/eval/results/logs/2026-10-15T12-45-37-124Z-004-phase-stats-armB/stdout.txt +1 -0
- package/eval/results/logs/2026-10-15T12-56-19-259Z-004-phase-stats-armA/prompt.txt +10 -0
- package/eval/results/logs/2026-10-15T12-56-19-259Z-004-phase-stats-armA/stderr.txt +0 -0
- package/eval/results/logs/2026-10-15T12-56-19-259Z-004-phase-stats-armA/stdout.txt +1 -0
- package/eval/results/logs/2026-10-15T13-00-05-625Z-004-phase-stats-armB/prompt.txt +10 -0
- package/eval/results/logs/2026-10-15T13-04-51-003Z-004-phase-stats-armA/prompt.txt +10 -0
- package/eval/results/logs/2026-10-15T13-04-51-003Z-004-phase-stats-armA/stderr.txt +0 -0
- package/eval/results/logs/2026-10-15T13-04-51-003Z-004-phase-stats-armA/stdout.txt +1 -0
- package/eval/results/logs/2026-10-15T13-06-25-271Z-004-phase-stats-armB/prompt.txt +10 -0
- package/eval/results/logs/2026-10-15T13-06-25-271Z-004-phase-stats-armB/stderr.txt +0 -0
- package/eval/results/logs/2026-10-15T13-06-25-271Z-004-phase-stats-armB/stdout.txt +1 -0
- package/eval/results/logs/2026-10-15T13-24-38-308Z-004-phase-stats-armB/prompt.txt +10 -0
- package/eval/results/logs/2026-10-15T13-24-38-308Z-004-phase-stats-armB/stderr.txt +0 -0
- package/eval/results/logs/2026-10-15T13-24-38-308Z-004-phase-stats-armB/stdout.txt +1 -0
- package/eval/results/logs/2026-10-15T14-50-01-950Z-005-orders-export-armA/prompt.txt +8 -0
- package/eval/results/logs/2026-10-15T14-50-01-950Z-005-orders-export-armA/stderr.txt +0 -0
- package/eval/results/logs/2026-10-15T14-50-01-950Z-005-orders-export-armA/stdout.txt +1 -0
- package/eval/results/logs/2026-10-15T14-51-56-316Z-005-orders-export-armB/prompt.txt +8 -0
- package/eval/results/logs/2026-10-15T14-51-56-316Z-005-orders-export-armB/stderr.txt +0 -0
- package/eval/results/logs/2026-10-15T14-51-56-316Z-005-orders-export-armB/stdout.txt +1 -0
- package/eval/results/workdirs/001-slugify__armA/package.json +6 -0
- package/eval/results/workdirs/001-slugify__armA/src/slugify.mjs +26 -0
- package/eval/results/workdirs/001-slugify__armB/package.json +6 -0
- package/eval/results/workdirs/001-slugify__armB/src/slugify.mjs +26 -0
- package/eval/results/workdirs/002-each-day__armA/package.json +6 -0
- package/eval/results/workdirs/002-each-day__armA/src/dates.mjs +30 -0
- package/eval/results/workdirs/002-each-day__armB/DRY_RUN_WRONG_ANSWER.mjs +1 -0
- package/eval/results/workdirs/002-each-day__armB/package.json +6 -0
- package/eval/results/workdirs/002-each-day__armB/src/dates.mjs +4 -0
- package/eval/results/workdirs/003-word-report__armA/bin/report.mjs +36 -0
- package/eval/results/workdirs/003-word-report__armA/data/notes.txt +5 -0
- package/eval/results/workdirs/003-word-report__armA/package.json +6 -0
- package/eval/results/workdirs/003-word-report__armB/bin/report.mjs +36 -0
- package/eval/results/workdirs/003-word-report__armB/data/notes.txt +5 -0
- package/eval/results/workdirs/003-word-report__armB/package.json +6 -0
- package/eval/results/workdirs/004-phase-stats__armA/data/events.log +16 -0
- package/eval/results/workdirs/004-phase-stats__armB/.claude/commands/sillyspec/flow.md +44 -0
- package/eval/results/workdirs/004-phase-stats__armB/.claude/commands/sillyspec/run-archive.md +27 -0
- package/eval/results/workdirs/004-phase-stats__armB/.claude/commands/sillyspec/run-brainstorm.md +34 -0
- package/eval/results/workdirs/004-phase-stats__armB/.claude/commands/sillyspec/run-execute.md +30 -0
- package/eval/results/workdirs/004-phase-stats__armB/.claude/commands/sillyspec/run-plan.md +28 -0
- package/eval/results/workdirs/004-phase-stats__armB/.claude/commands/sillyspec/run-verify.md +30 -0
- package/eval/results/workdirs/004-phase-stats__armB/.claude/commands/sillyspec/status.md +28 -0
- package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-archive/SKILL.md +87 -0
- package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-auto/SKILL.md +54 -0
- package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-brainstorm/SKILL.md +114 -0
- package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-commit/SKILL.md +67 -0
- package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-continue/SKILL.md +17 -0
- package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-doctor/SKILL.md +76 -0
- package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-execute/SKILL.md +222 -0
- package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-explore/SKILL.md +107 -0
- package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-export/SKILL.md +57 -0
- package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-flow/SKILL.md +62 -0
- package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-init/SKILL.md +177 -0
- package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-knowledge/SKILL.md +40 -0
- package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-plan/SKILL.md +156 -0
- package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-propose/SKILL.md +16 -0
- package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-quick/SKILL.md +116 -0
- package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-resume/SKILL.md +21 -0
- package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-scan/SKILL.md +79 -0
- package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-state/SKILL.md +25 -0
- package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-status/SKILL.md +54 -0
- package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-verify/SKILL.md +87 -0
- package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-workspace/SKILL.md +33 -0
- package/eval/results/workdirs/004-phase-stats__armB/AGENTS.md +45 -0
- package/eval/results/workdirs/004-phase-stats__armB/CLAUDE.md +3 -0
- package/eval/results/workdirs/004-phase-stats__armB/data/events.log +16 -0
- package/eval/results/workdirs/run-001-slugify__armA/package.json +6 -0
- package/eval/results/workdirs/run-001-slugify__armA/src/slugify.mjs +26 -0
- package/eval/results/workdirs/run-001-slugify__armB/package.json +6 -0
- package/eval/results/workdirs/run-001-slugify__armB/src/slugify.mjs +26 -0
- package/eval/results/workdirs/run-002-each-day__armA/package.json +6 -0
- package/eval/results/workdirs/run-002-each-day__armA/src/dates.mjs +30 -0
- package/eval/results/workdirs/run-002-each-day__armB/DRY_RUN_WRONG_ANSWER.mjs +1 -0
- package/eval/results/workdirs/run-002-each-day__armB/package.json +6 -0
- package/eval/results/workdirs/run-002-each-day__armB/src/dates.mjs +4 -0
- package/eval/results/workdirs/run-003-word-report__armA/bin/report.mjs +36 -0
- package/eval/results/workdirs/run-003-word-report__armA/data/notes.txt +5 -0
- package/eval/results/workdirs/run-003-word-report__armA/package.json +6 -0
- package/eval/results/workdirs/run-003-word-report__armB/bin/report.mjs +36 -0
- package/eval/results/workdirs/run-003-word-report__armB/data/notes.txt +5 -0
- package/eval/results/workdirs/run-003-word-report__armB/package.json +6 -0
- package/eval/results/workdirs/run-004-phase-stats__armA/bin/phase-stats.mjs +37 -0
- package/eval/results/workdirs/run-004-phase-stats__armA/data/events.log +16 -0
- package/eval/results/workdirs/run-004-phase-stats__armB/DRY_RUN_WRONG_ANSWER.mjs +1 -0
- package/eval/results/workdirs/run-004-phase-stats__armB/data/events.log +16 -0
- package/eval/results/workdirs/run-005-orders-export__armA/bin/export-orders.mjs +57 -0
- package/eval/results/workdirs/run-005-orders-export__armA/data/.gitattributes +1 -0
- package/eval/results/workdirs/run-005-orders-export__armA/data/orders.csv +9 -0
- package/eval/results/workdirs/run-005-orders-export__armA/out/summary.json +1 -0
- package/eval/results/workdirs/run-005-orders-export__armB/bin/export-orders.mjs +57 -0
- package/eval/results/workdirs/run-005-orders-export__armB/data/.gitattributes +1 -0
- package/eval/results/workdirs/run-005-orders-export__armB/data/orders.csv +9 -0
- package/eval/results/workdirs/run-005-orders-export__armB/out/summary.json +1 -0
- package/eval/tasks/004-phase-stats/files/data/events.log +16 -0
- package/eval/tasks/004-phase-stats/solution/bin/phase-stats.mjs +37 -0
- package/eval/tasks/004-phase-stats/task.json +6 -0
- package/eval/tasks/004-phase-stats/verify.mjs +38 -0
- package/eval/tasks/005-orders-export/files/data/.gitattributes +1 -0
- package/eval/tasks/005-orders-export/files/data/orders.csv +9 -0
- package/eval/tasks/005-orders-export/solution/bin/export-orders.mjs +57 -0
- package/eval/tasks/005-orders-export/task.json +6 -0
- package/eval/tasks/005-orders-export/verify.mjs +28 -0
- package/package.json +1 -1
- package/quicklog/tmp-fix-review.mjs +21 -0
- package/src/ceremony-tier.js +2 -1
- package/src/commit-suggest.js +45 -4
- package/src/config-schema.js +14 -0
- package/src/decision-distill.js +106 -24
- package/src/decision-redomain.js +148 -0
- package/src/decision-renumber.js +157 -0
- package/src/design-facts.js +3 -3
- package/src/docs-check.js +30 -15
- package/src/docs-debt.js +6 -5
- package/src/doctor-diagnostics.js +3 -1
- package/src/flow-parity.js +28 -4
- package/src/flow.js +76 -13
- package/src/forensics.js +236 -0
- package/src/fr-index.js +17 -2
- package/src/hooks/worktree-guard.js +3 -3
- package/src/index.js +41 -6
- package/src/knowledge-digest.js +23 -6
- package/src/knowledge-graph.js +95 -18
- 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/module-impact.js +2 -5
- package/src/modules.js +31 -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 +100 -103
- package/src/run/lint-ownership.js +37 -0
- package/src/run/prompt.js +43 -28
- package/src/run/stage.js +1 -1
- package/src/run/verify-quality-scan.js +10 -1
- package/src/sentinel-assertions.js +43 -1
- package/src/spec-sync.js +81 -2
- package/src/stage-contract.js +26 -3
- package/src/stage-review.js +46 -12
- package/src/stage-templates.js +2 -0
- package/src/stages/brainstorm.js +76 -21
- package/src/stages/knowledge.js +56 -5
- package/src/stages/plan-postcheck.js +13 -2
- package/src/stages/plan.js +19 -0
- package/src/sync.js +28 -2
- package/src/task-tick.js +12 -0
- package/src/test-bindings.js +32 -3
- package/src/timeline.js +60 -19
- package/src/ui-visual.js +119 -4
- package/src/verify-postcheck.js +166 -18
- package/src/verify-probes.js +61 -18
- package/src/worktree-apply.js +26 -2
- package/templates/agents-instruction.md +1 -1
package/src/decision-distill.js
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
* 零输出:无 decisions.md 或无入选条目 → skipped 带原因,不写任何文件、不动 INDEX。
|
|
23
23
|
*/
|
|
24
24
|
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
|
|
25
|
+
import { modulePathMatches } from './modules.js'
|
|
25
26
|
import { basename, dirname, join } from 'node:path'
|
|
26
27
|
|
|
27
28
|
/** FR-02:有实现影响的五类 type(scope 与未知 type 的 confirmed/accepted 不入选) */
|
|
@@ -36,7 +37,29 @@ const UNMAPPED_DOMAIN = 'unmapped'
|
|
|
36
37
|
/** 旧格式容错占位值(FR-01:缺锚点/缺 headHash 不阻断) */
|
|
37
38
|
const NOT_RECORDED = '未记录'
|
|
38
39
|
|
|
39
|
-
/**
|
|
40
|
+
/**
|
|
41
|
+
* 锚点自动回填候选(2026-10-15-decision-anchor-backfill 单一源;收割端 flow-parity 与
|
|
42
|
+
* 蒸馏渲染端共用)。条目正文(答案/理由散文)中的路径形态 token——「恰一个唯一」即高置信
|
|
43
|
+
* 落点,返回该 token;多候选/无 token 返回 null(保守:错锚比无锚更误导 impact 查询与
|
|
44
|
+
* 悬空检查)。收益率探针(2026-10-15):恰一路径口径救 28%(本仓 174/616、平台 150/543)。
|
|
45
|
+
* 过滤黑名单(存量干跑实测坏锚类):知识治理面(.sillyspec/)、运行时面(.runtime/)、
|
|
46
|
+
* 归档与变更工件(changes/、allowed_paths 段)、知识互引(knowledge/、decisions/、fr/)、
|
|
47
|
+
* 消费仓坑文档目录(docs/sillyspec/)、前导 / 路径碎片、遥测日志扩展(.jsonl/.log)、
|
|
48
|
+
* repo:// 跨仓协议前缀剥除。token 须带扩展名收尾(:行号 形态锚点契约原生支持)。
|
|
49
|
+
* @param {string} text 条目正文拼接(答案/理由/question 等)
|
|
50
|
+
* @returns {string|null}
|
|
51
|
+
*/
|
|
52
|
+
export function anchorBackfillCandidate(text) {
|
|
53
|
+
const raw = String(text || '').match(/[A-Za-z0-9_\-.\\/:]+/g) || []
|
|
54
|
+
const pathed = [...new Set(raw
|
|
55
|
+
.map((t) => t.replace(/\\/g, '/').replace(/^repo:\/\//, '').replace(/\/+$/, '').replace(/:$/, ''))
|
|
56
|
+
.filter((t) => t.includes('/')
|
|
57
|
+
&& /\.[A-Za-z0-9]{1,10}(:\d+)?$/.test(t)
|
|
58
|
+
&& !/^(\.sillyspec|\.runtime|changes|knowledge|decisions|fr|docs\/sillyspec|allowed_paths)(\/|$)/.test(t)
|
|
59
|
+
&& !t.startsWith('/')
|
|
60
|
+
&& !/\.(jsonl|log)$/i.test(t)))]
|
|
61
|
+
return pathed.length === 1 ? pathed[0] : null
|
|
62
|
+
}/** 多行/带空白值压成单行字段值(条目行是单行契约) */
|
|
40
63
|
function oneLine(s) {
|
|
41
64
|
return String(s ?? '').replace(/\s*\r?\n\s*/g, ' ').trim()
|
|
42
65
|
}
|
|
@@ -274,7 +297,7 @@ function matchImpactsToModules(impacts, moduleIndex) {
|
|
|
274
297
|
const hits = []
|
|
275
298
|
for (const t of tokens) {
|
|
276
299
|
for (const { id, p } of modPaths) {
|
|
277
|
-
if (t
|
|
300
|
+
if (modulePathMatches(t, p)) { if (!hits.includes(id)) hits.push(id); break }
|
|
278
301
|
}
|
|
279
302
|
}
|
|
280
303
|
if (hits.length === 0) {
|
|
@@ -313,11 +336,17 @@ function newDomainPreamble(domain) {
|
|
|
313
336
|
* 变更:行(2026-08-28,坑 distill-cross-change-supersede):D-xxx 编号是变更内局部序号,
|
|
314
337
|
* 跨变更同号(两个 D-002)互不相干——条目必须携带变更名限定,消费方(knowledge-match/
|
|
315
338
|
* docs-check 的字段行解析)只认各自标签,新增字段行是增量安全。 */
|
|
316
|
-
function renderBlockLines(entry, headHash, supersedesNote, changeName) {
|
|
317
|
-
const lines = [`## ${entry.
|
|
339
|
+
function renderBlockLines(entry, headHash, supersedesNote, changeName, sectionNumber, originId) {
|
|
340
|
+
const lines = [`## ${sectionNumber}@v${entry.version} ${oneLine(entry.title)}`.replace(/\s+$/, '')]
|
|
318
341
|
lines.push(`状态:${entry.selected}`)
|
|
319
342
|
if (changeName) lines.push(`变更:${oneLine(changeName)}`)
|
|
320
|
-
|
|
343
|
+
// 原编号(2026-10-14-decision-global-id-epoch):epoch 形态溯源字段——变更行后/锚点行前
|
|
344
|
+
// (不破「文件:紧跟锚点」行序红线);legacy 复用段(originId=null)不渲染,形态零变化
|
|
345
|
+
if (originId) lines.push(`原编号:${oneLine(originId)}`)
|
|
346
|
+
// 锚点自动回填(2026-10-15-decision-anchor-backfill):源条目未填锚点时,从条目正文
|
|
347
|
+
// (答案/理由)提取路径形态 token——「恰一个唯一」即高置信落点(正文明确只谈一个文件),
|
|
348
|
+
// 多候选/无 token 保持未记录(保守:错锚比无锚更误导 impact 查询与悬空检查)。
|
|
349
|
+
lines.push(`锚点:${oneLine(entry.anchor) || anchorBackfillCandidate([entry.answer, entry.reason, entry.normalizedRequirement, entry.question].join('\n')) || NOT_RECORDED}`)
|
|
321
350
|
// 文件行(FR-01/D-001@v1):仅非空渲染——存量条目零迁移,幂等重归档不添空行
|
|
322
351
|
if (entry.files && entry.files.length > 0) lines.push(`文件:${entry.files.join(', ')}`)
|
|
323
352
|
lines.push(`最近确认:${oneLine(headHash) || NOT_RECORDED}`)
|
|
@@ -336,12 +365,14 @@ function renderBlockLines(entry, headHash, supersedesNote, changeName) {
|
|
|
336
365
|
}
|
|
337
366
|
|
|
338
367
|
/** 知识文件切段:preamble 行 + 条目段(段边界 = 下一节头)。
|
|
339
|
-
* 段的变更归属取段内 `变更:<name>`
|
|
340
|
-
*
|
|
368
|
+
* 段的变更归属取段内 `变更:<name>` 行,「来源:<name>` 兜底(2026-10-14-decision-id-p2-hardening:
|
|
369
|
+
* 08-24 首修形态残留——读出后带来源的 legacy 段参与同变更同号匹配/supersede,重蒸馏在位更新收敛);
|
|
370
|
+
* 无任何限定行的历史条目 → change=null(legacy:升级前落库的条目,不参与新条目的同号匹配——只共存不误删)。
|
|
341
371
|
* 2026-09-18-fr-index-l1 参数化:sectionRegex/buildId 经参(decisions 缺省不变;fr 侧传 FR 节头
|
|
342
372
|
* 正则与恒等 id),decisions 调用点零改动。 */
|
|
343
373
|
export function splitKnowledgeSections(content, opts = {}) {
|
|
344
|
-
|
|
374
|
+
// 节头号双形态(2026-10-14-decision-global-id-epoch):局部 D-011@v1 与全局 D-<域>-042@v1 并存
|
|
375
|
+
const sectionRegex = opts.sectionRegex || /^## (D-(?:[A-Za-z0-9_\-\u4e00-\u9fff]+-)?\d+)@v(\d+)\s*(.*)$/
|
|
345
376
|
const buildId = opts.buildId || ((n, v) => `${n}@v${v}`)
|
|
346
377
|
const sections = []
|
|
347
378
|
const preamble = []
|
|
@@ -359,8 +390,15 @@ export function splitKnowledgeSections(content, opts = {}) {
|
|
|
359
390
|
if (cur) sections.push(cur)
|
|
360
391
|
for (const s of sections) {
|
|
361
392
|
s.id = buildId(s.number, s.version)
|
|
362
|
-
|
|
363
|
-
|
|
393
|
+
// 「变更:」优先、「来源:」兜底(行内前缀按命中行剥除;无任何限定行 → null 不互认)
|
|
394
|
+
const cm = s.lines.find(l => /^变更[::]/.test(l)) || s.lines.find(l => /^来源[::]/.test(l))
|
|
395
|
+
s.change = cm ? cm.replace(/^(变更|来源)[::]\s*/, '').trim() || null : null
|
|
396
|
+
// 原编号(2026-10-14-decision-global-id-epoch 溯源字段):「原编号:D-011@v1」→ origin/originNumber;
|
|
397
|
+
// legacy 段无该行 → null(幂等匹配谓词按号兜底)
|
|
398
|
+
const oi = s.lines.find(l => /^原编号[::]/.test(l))
|
|
399
|
+
s.origin = oi ? oi.replace(/^原编号[::]\s*/, '').trim() || null : null
|
|
400
|
+
const on = s.origin ? /^(D-(?:[A-Za-z0-9_\-\u4e00-\u9fff]+-)?\d+)@v?\d+/.exec(s.origin) : null
|
|
401
|
+
s.originNumber = on ? on[1] : null
|
|
364
402
|
}
|
|
365
403
|
return { preamble, sections }
|
|
366
404
|
}
|
|
@@ -385,14 +423,23 @@ export function joinKnowledgeFile(preamble, sections) {
|
|
|
385
423
|
* 升级前落库)不与任何新条目互认,只共存不误删。
|
|
386
424
|
* @returns {{ action: 'append'|'update'|'supersede' }|null} null = 同变更同号高版本已落库,迟到低版本不降级。
|
|
387
425
|
*/
|
|
388
|
-
|
|
426
|
+
/** 同变更同段匹配谓词(2026-10-14-decision-global-id-epoch 扩展):变更一致 且
|
|
427
|
+
* (段号==条目局部号 或 段.原编号号段==条目局部号)——legacy 段按号匹配、epoch 段按原编号匹配。
|
|
428
|
+
* @returns {{action: 'append'|'update'|'supersede'}|null} null = 同变更同段高版本已落库,迟到低版本不降级。 */
|
|
429
|
+
function sameDecisionNumber(s, entry) {
|
|
430
|
+
return (s.change || null) === (entry.change || null) && (s.number === entry.number || s.originNumber === entry.number)
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function applyEntryToSections(sections, entry, headHash, sectionNumber, originId) {
|
|
389
434
|
const change = entry.change || null
|
|
390
|
-
const sameNumber = sections.filter(s => s
|
|
435
|
+
const sameNumber = sections.filter(s => sameDecisionNumber(s, entry))
|
|
391
436
|
if (sameNumber.some(s => s.version > entry.version)) return null
|
|
392
437
|
let action
|
|
393
438
|
let supersedesNote = oneLine(entry.supersedes) || null
|
|
439
|
+
const sectionId = `${sectionNumber}@v${entry.version}`
|
|
440
|
+
const originNumber = originId ? (/^(D-(?:[A-Za-z0-9_\-\u4e00-\u9fff]+-)?\d+)/.exec(originId) || [])[1] || null : null
|
|
394
441
|
if (sameNumber.length === 0) {
|
|
395
|
-
sections.push({ number:
|
|
442
|
+
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
443
|
action = 'append'
|
|
397
444
|
} else {
|
|
398
445
|
const same = sameNumber.find(s => s.version === entry.version)
|
|
@@ -400,10 +447,14 @@ function applyEntryToSections(sections, entry, headHash) {
|
|
|
400
447
|
if (!same && !supersedesNote) supersedesNote = `${slot.number}@v${slot.version}`
|
|
401
448
|
slot.version = entry.version
|
|
402
449
|
slot.title = entry.title
|
|
403
|
-
slot.
|
|
404
|
-
|
|
450
|
+
slot.number = sectionNumber
|
|
451
|
+
slot.id = sectionId
|
|
452
|
+
slot.origin = originId
|
|
453
|
+
slot.originNumber = originNumber
|
|
454
|
+
slot.lines = renderBlockLines(entry, headHash, supersedesNote, change || undefined, sectionNumber, originId)
|
|
455
|
+
// 同变更同段(号或原编号匹配)旧版本就地清除——同文件内同变更同段只留一个段
|
|
405
456
|
for (let i = sections.length - 1; i >= 0; i--) {
|
|
406
|
-
if (sections[i]
|
|
457
|
+
if (sameDecisionNumber(sections[i], entry) && sections[i] !== slot) sections.splice(i, 1)
|
|
407
458
|
}
|
|
408
459
|
action = same ? 'update' : 'supersede'
|
|
409
460
|
}
|
|
@@ -577,31 +628,62 @@ export function distillIntoKnowledge(changeDir, knowledgeRoot, headHash, moduleI
|
|
|
577
628
|
}
|
|
578
629
|
|
|
579
630
|
const written = []
|
|
631
|
+
// 域全集(跨文件清理与复用扫描共用;磁盘 ∪ 本批已加载)
|
|
632
|
+
let onDisk = []
|
|
633
|
+
try { onDisk = readdirSync(decisionsDir).filter(f => f.endsWith('.md')).map(f => f.replace(/\.md$/, '')) } catch { /* 刚创建,走 store */ }
|
|
634
|
+
let allDomains = [...new Set([...onDisk, ...store.keys()])]
|
|
635
|
+
// 发号器(2026-10-14-decision-global-id-epoch):域前缀全域 max+1 只增不复用;mintMax 批内会话
|
|
636
|
+
// 计数防同批撞号(磁盘扫描只做初值);并发蒸馏撞号窗口与 FR 侧发号同病(接受,doctor 可见)。
|
|
637
|
+
const mintMax = new Map()
|
|
638
|
+
const mintFor = (domain) => {
|
|
639
|
+
if (!mintMax.has(domain)) {
|
|
640
|
+
let max = 0
|
|
641
|
+
for (const f of onDisk) {
|
|
642
|
+
let c; try { c = readFileSync(join(decisionsDir, f.endsWith('.md') ? f : `${f}.md`), 'utf8') } catch { continue }
|
|
643
|
+
const re = /^## D-([A-Za-z0-9_\-\u4e00-\u9fff]+?)-(\d+)@v\d+/gm
|
|
644
|
+
let m
|
|
645
|
+
while ((m = re.exec(c)) !== null) if (m[1] === domain) max = Math.max(max, parseInt(m[2], 10))
|
|
646
|
+
}
|
|
647
|
+
mintMax.set(domain, max)
|
|
648
|
+
}
|
|
649
|
+
const next = mintMax.get(domain) + 1
|
|
650
|
+
mintMax.set(domain, next)
|
|
651
|
+
return `D-${domain}-${String(next).padStart(3, '0')}`
|
|
652
|
+
}
|
|
580
653
|
const targetSets = new Map() // entry → Set(域)(跨文件清理时排除目标文件)
|
|
581
654
|
for (const entry of batch) {
|
|
582
655
|
const domains = resolveDomains(entry, idx)
|
|
583
656
|
targetSets.set(entry, new Set(domains))
|
|
657
|
+
// 号决策(2026-10-14-decision-global-id-epoch):同变更同段存量(全域文件扫描,号或原编号匹配)
|
|
658
|
+
// 复用其号——legacy 段保持局部形态(不补原编号不改前缀,存量零触碰);epoch 段原编号随版本更新;
|
|
659
|
+
// 无匹配 → 发号域=首域(resolveDomains 已 sanitize)批内 max+1,扇出多文件同一号(单一身份)。
|
|
660
|
+
let reuse = null
|
|
661
|
+
for (const d of allDomains) {
|
|
662
|
+
const st = loadDomain(d)
|
|
663
|
+
reuse = st.sections.find(s => sameDecisionNumber(s, entry)) || null
|
|
664
|
+
if (reuse) break
|
|
665
|
+
}
|
|
666
|
+
const sectionNumber = reuse ? reuse.number : mintFor(domains[0])
|
|
667
|
+
const originId = reuse && !reuse.origin ? null : `${entry.number}@v${entry.version}`
|
|
584
668
|
for (const d of domains) {
|
|
585
669
|
const st = loadDomain(d)
|
|
586
|
-
const res = applyEntryToSections(st.sections, entry, headHash)
|
|
670
|
+
const res = applyEntryToSections(st.sections, entry, headHash, sectionNumber, originId)
|
|
587
671
|
if (res) {
|
|
588
672
|
st.dirty = true
|
|
589
|
-
written.push({ file: `decisions/${d}.md`, id: entry.
|
|
673
|
+
written.push({ file: `decisions/${d}.md`, id: `${sectionNumber}@v${entry.version}`, action: res.action })
|
|
590
674
|
}
|
|
591
675
|
}
|
|
676
|
+
allDomains = [...new Set([...allDomains, ...domains])]
|
|
592
677
|
}
|
|
593
678
|
|
|
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()])]
|
|
679
|
+
// 跨文件清理:同变更同段(号或原编号匹配)≤ 新版本的旧段散落其他域文件时移除(同变更同段
|
|
680
|
+
// 全局只留最高版本/目标文件);他者变更的同号段不参与(共存,坑 distill-cross-change-supersede)
|
|
599
681
|
for (const entry of batch) {
|
|
600
682
|
const targets = targetSets.get(entry)
|
|
601
683
|
for (const d of allDomains) {
|
|
602
684
|
if (targets.has(d)) continue
|
|
603
685
|
const st = loadDomain(d)
|
|
604
|
-
const removed = st.sections.filter(s => s.
|
|
686
|
+
const removed = st.sections.filter(s => s.version <= entry.version && sameDecisionNumber(s, entry))
|
|
605
687
|
if (removed.length === 0) continue
|
|
606
688
|
st.sections = st.sections.filter(s => !removed.includes(s))
|
|
607
689
|
st.dirty = true
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* decision-redomain.js — 决策条目跨域迁移(2026-10-15-decision-redomain)
|
|
3
|
+
*
|
|
4
|
+
* 背景:模块路径 glob 匹配失效(2026-10-15-module-path-glob-match)导致消费仓历史决策
|
|
5
|
+
* 大量落 decisions/unmapped.md(平台仓 442 条实证)——匹配器修复止增量后,存量需机器
|
|
6
|
+
* 通道清偿。redomain.js 只管 fr/ 条目;本模块为 decisions/ 面的孪生实现,复用
|
|
7
|
+
* decision-distill 参数化底座(splitKnowledgeSections/joinKnowledgeFile/syncIndexRoutingLines)。
|
|
8
|
+
*
|
|
9
|
+
* 与 FR 版的形态差异:
|
|
10
|
+
* - 段头 `## D-xxx@vN 标题`(全局号/局部号双形态混存)——默认 sectionRegex 即此格式;
|
|
11
|
+
* id = `<number>@v<version>`(迁域不换号,字段行内「原编号」等机器行随段整体搬)。
|
|
12
|
+
* - 跨变更同号合法共存(D 号是变更内局部序号)——冲突键 = id + 变更(目标域已有
|
|
13
|
+
* 同 id 同变更段 = 迁移语义不明,跳该段并报告,不覆盖)。
|
|
14
|
+
* - 路由行形态与 fr 不同(`- <域>|decision|决策 → [decisions/<域>.md]`)——syncIndexRoutingLines
|
|
15
|
+
* 默认参数即 decisions 面。
|
|
16
|
+
*
|
|
17
|
+
* 幂等:重跑同批——源域已无该段 → 0 条(或 anchor/byChange 无命中报错,与 FR 版同款)。
|
|
18
|
+
*/
|
|
19
|
+
import { existsSync, readFileSync, unlinkSync } from 'fs'
|
|
20
|
+
import { join } from 'path'
|
|
21
|
+
import { splitKnowledgeSections, joinKnowledgeFile, syncIndexRoutingLines } from './decision-distill.js'
|
|
22
|
+
import { writeAtomicSync } from './fs-atomic.js'
|
|
23
|
+
|
|
24
|
+
const DECISION_HEADER_RE = /^## (D-(?:[A-Za-z0-9_\-\u4e00-\u9fff]+-)?\d+)@v(\d+)\s*(.*)$/
|
|
25
|
+
|
|
26
|
+
/** 目标域文件头(与 decision-distill 落域文件既有头逐字同款——平台真实库实测形态) */
|
|
27
|
+
function newDomainPreamble(domain) {
|
|
28
|
+
return [
|
|
29
|
+
`# 决策知识 — ${domain}`,
|
|
30
|
+
'',
|
|
31
|
+
'> decision-distill 从变更 decisions.md 幂等提炼(「最近确认」= 归档时 HEAD)。条目字段行为 docs-check 机械解析契约,勿手改。',
|
|
32
|
+
'',
|
|
33
|
+
]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 段 → {id, number, version, title, change}(id=number@version 即迁域不换号的身份) */
|
|
37
|
+
function sectionMeta(s) {
|
|
38
|
+
const m = DECISION_HEADER_RE.exec(s.lines[0] || '')
|
|
39
|
+
if (!m) return null
|
|
40
|
+
const id = `${m[1]}@v${m[2]}`
|
|
41
|
+
const cm = s.lines.find((l) => /^变更[::]/.test(l)) || s.lines.find((l) => /^来源[::]/.test(l))
|
|
42
|
+
const change = cm ? cm.replace(/^(变更|来源)[::]\s*/, '').trim() || null : null
|
|
43
|
+
return { id, number: m[1], version: parseInt(m[2], 10), title: (m[3] || '').trim(), change }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function normalizeDomainArg(v) {
|
|
47
|
+
return String(v ?? '').trim().replace(/^decisions\//i, '').replace(/\.md$/i, '').trim()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 干跑预览:列出将迁移条目,不落盘(anchor/byChange 同执行侧过滤) */
|
|
51
|
+
export function planDecisionRedomain({ knowledgeRoot, from, to, anchors = null, byChange = null }) {
|
|
52
|
+
from = normalizeDomainArg(from)
|
|
53
|
+
to = normalizeDomainArg(to)
|
|
54
|
+
if (!from || !to) throw new Error('用法:--from <源域> --to <目标域>(域名可带 decisions/ 前缀与 .md 后缀)')
|
|
55
|
+
if (from === to) throw new Error('源域与目标域相同')
|
|
56
|
+
const dir = join(knowledgeRoot, 'decisions')
|
|
57
|
+
const fromPath = join(dir, `${from}.md`)
|
|
58
|
+
if (!existsSync(fromPath)) throw new Error(`源域文件不存在:decisions/${from}.md`)
|
|
59
|
+
const parsed = splitKnowledgeSections(readFileSync(fromPath, 'utf8'))
|
|
60
|
+
const anchorSet = Array.isArray(anchors) && anchors.length > 0 ? new Set(anchors) : null
|
|
61
|
+
const entries = []
|
|
62
|
+
for (const s of parsed.sections) {
|
|
63
|
+
const meta = sectionMeta(s)
|
|
64
|
+
if (!meta) continue
|
|
65
|
+
if (anchorSet && !anchorSet.has(meta.id)) continue
|
|
66
|
+
if (byChange && meta.change !== byChange) continue
|
|
67
|
+
entries.push(meta)
|
|
68
|
+
}
|
|
69
|
+
if (entries.length === 0) {
|
|
70
|
+
throw new Error(anchorSet
|
|
71
|
+
? `anchor 未命中(${[...anchorSet].join('、')} 不在 decisions/${from}.md)`
|
|
72
|
+
: byChange ? `「变更:${byChange}」条目在 decisions/${from}.md 无命中` : `decisions/${from}.md 无决策条目段`)
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
entries,
|
|
76
|
+
targetExists: existsSync(join(dir, `${to}.md`)),
|
|
77
|
+
sourceWillDelete: parsed.sections.length - entries.length === 0,
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 执行迁移(纯文件手术——段字节整体搬运,id/机器行零改写) */
|
|
82
|
+
export function redomainDecisionEntries({ knowledgeRoot, from, to, anchors = null, byChange = null }) {
|
|
83
|
+
from = normalizeDomainArg(from)
|
|
84
|
+
to = normalizeDomainArg(to)
|
|
85
|
+
if (!from || !to) throw new Error('用法:--from <源域> --to <目标域>')
|
|
86
|
+
if (from === to) throw new Error('源域与目标域相同')
|
|
87
|
+
const dir = join(knowledgeRoot, 'decisions')
|
|
88
|
+
const fromPath = join(dir, `${from}.md`)
|
|
89
|
+
const toPath = join(dir, `${to}.md`)
|
|
90
|
+
if (!existsSync(fromPath)) throw new Error(`源域文件不存在:decisions/${from}.md`)
|
|
91
|
+
|
|
92
|
+
const fromParsed = splitKnowledgeSections(readFileSync(fromPath, 'utf8'))
|
|
93
|
+
const anchorSet = Array.isArray(anchors) && anchors.length > 0 ? new Set(anchors) : null
|
|
94
|
+
const targetCreated = !existsSync(toPath)
|
|
95
|
+
const toParsed = targetCreated
|
|
96
|
+
? { preamble: newDomainPreamble(to), sections: [] }
|
|
97
|
+
: splitKnowledgeSections(readFileSync(toPath, 'utf8'))
|
|
98
|
+
|
|
99
|
+
const moving = []
|
|
100
|
+
const staying = []
|
|
101
|
+
const skippedConflicts = []
|
|
102
|
+
const movedKeys = new Set() // 批内双键(id|change):同批重复段第二条跳过(2026-10-15 平台存量实证幂等残留)
|
|
103
|
+
for (const s of fromParsed.sections) {
|
|
104
|
+
const meta = sectionMeta(s)
|
|
105
|
+
if (!meta) { staying.push(s); continue } // 非条目段(说明/杂项)留守
|
|
106
|
+
if (anchorSet && !anchorSet.has(meta.id)) { staying.push(s); continue }
|
|
107
|
+
if (byChange && meta.change !== byChange) { staying.push(s); continue }
|
|
108
|
+
// 冲突防线:目标域已有同 id + 同变更段(跨变更同号合法共存,双键才冲突)
|
|
109
|
+
const key = `${meta.id}|${meta.change || ''}`
|
|
110
|
+
const clash = movedKeys.has(key) || toParsed.sections.some((t) => {
|
|
111
|
+
const tm = sectionMeta(t)
|
|
112
|
+
return tm && `${tm.id}|${tm.change || ''}` === key
|
|
113
|
+
})
|
|
114
|
+
if (clash) { skippedConflicts.push(meta.id); staying.push(s); continue }
|
|
115
|
+
movedKeys.add(key)
|
|
116
|
+
moving.push({ meta, section: s })
|
|
117
|
+
}
|
|
118
|
+
if (moving.length === 0 && skippedConflicts.length === 0) {
|
|
119
|
+
throw new Error(anchorSet
|
|
120
|
+
? `anchor 未命中(${[...anchorSet].join('、')} 不在 decisions/${from}.md)`
|
|
121
|
+
: byChange ? `「变更:${byChange}」条目在 decisions/${from}.md 无命中` : `decisions/${from}.md 无决策条目段`)
|
|
122
|
+
}
|
|
123
|
+
if (anchorSet && skippedConflicts.length + moving.length !== anchorSet.size) {
|
|
124
|
+
const got = new Set([...moving.map((m) => m.meta.id), ...skippedConflicts])
|
|
125
|
+
const miss = [...anchorSet].filter((a) => !got.has(a))
|
|
126
|
+
if (miss.length > 0) throw new Error(`anchor 未命中:${miss.join('、')}(不在 decisions/${from}.md)`)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
toParsed.sections.push(...moving.map((m) => m.section))
|
|
130
|
+
writeAtomicSync(toPath, joinKnowledgeFile(toParsed.preamble, toParsed.sections))
|
|
131
|
+
const sourceDeleted = staying.length === 0
|
|
132
|
+
if (sourceDeleted) unlinkSync(fromPath)
|
|
133
|
+
else writeAtomicSync(fromPath, joinKnowledgeFile(fromParsed.preamble, staying))
|
|
134
|
+
|
|
135
|
+
// INDEX 路由行:decisions 面默认参数(幂等;缺席行补上)
|
|
136
|
+
const indexPath = join(knowledgeRoot, 'INDEX.md')
|
|
137
|
+
const before = existsSync(indexPath) ? readFileSync(indexPath, 'utf8') : ''
|
|
138
|
+
try { syncIndexRoutingLines(knowledgeRoot) } catch { /* INDEX 缺席/同步失败不拦迁移 */ }
|
|
139
|
+
const after = existsSync(indexPath) ? readFileSync(indexPath, 'utf8') : ''
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
moved: moving.map((m) => ({ id: m.meta.id, title: m.meta.title })),
|
|
143
|
+
skippedConflicts,
|
|
144
|
+
sourceDeleted,
|
|
145
|
+
targetCreated,
|
|
146
|
+
indexLineAdded: after !== before,
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -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/design-facts.js
CHANGED
|
@@ -26,7 +26,7 @@ import { join, relative } from 'node:path'
|
|
|
26
26
|
import { parseDecisions } from './decision-distill.js'
|
|
27
27
|
import { parseFileChangeListDetailed } from './change-list.js'
|
|
28
28
|
import { parseDesignCoverageByRepo, parseRepoRegistry } from './stages/plan-postcheck.js'
|
|
29
|
-
import { parseModuleMapSimple } from './modules.js'
|
|
29
|
+
import { modulePathMatches, parseModuleMapSimple } from './modules.js'
|
|
30
30
|
|
|
31
31
|
// ---------------------------------------------------------------------------
|
|
32
32
|
// 条目解析(distill parseDecisions 的同源镜像,R-01)
|
|
@@ -173,7 +173,7 @@ export function deriveActualModules(filePaths, prefixPairs) {
|
|
|
173
173
|
const t = String(f || '').replace(/\\/g, '/').replace(/\/+$/, '')
|
|
174
174
|
if (!t) continue
|
|
175
175
|
for (const { id, path: p } of prefixPairs) {
|
|
176
|
-
if (t
|
|
176
|
+
if (modulePathMatches(t, p)) { actual.add(id); break }
|
|
177
177
|
}
|
|
178
178
|
}
|
|
179
179
|
return actual
|
|
@@ -387,7 +387,7 @@ export function generateDesignSkeleton({ changeName, decisionsText, author, now
|
|
|
387
387
|
lines.push('- [ ] frontmatter 字段齐全(author/created_at/scale)')
|
|
388
388
|
lines.push('- [ ] 引用所有当前版本 D-xxx@vN')
|
|
389
389
|
lines.push('- [ ] 涉及生命周期关键词时含「生命周期契约表」或紧邻豁免短语')
|
|
390
|
-
lines.push('- [ ] UI 原型分级核对(涉前端文件时变更目录应有 prototype-*.html
|
|
390
|
+
lines.push('- [ ] UI 原型分级核对(涉前端文件时变更目录应有 prototype-*.html——新生成(定稿=与实现同方言的真码产物,手绘仅限粗选,输出须带素材来源声明:token 源/组件来源/入口位置)或登记复用(复用须按 prototype-<名称>.html 命名,execute 才注入)或跳过原因记入风险登记;本仓形态未在 local.yaml ui_prototype 登记的,收口前补登记)')
|
|
391
391
|
lines.push('- [ ] 不确定的问题标注「⚠️ 自审存疑」')
|
|
392
392
|
lines.push('')
|
|
393
393
|
|