sillyspec 3.28.11 → 3.28.13
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-execute/SKILL.md +222 -223
- package/.claude/skills/sillyspec-plan/SKILL.md +156 -156
- package/.claude/skills/sillyspec-quick/SKILL.md +8 -8
- package/.claude/skills/sillyspec-verify/SKILL.md +87 -91
- package/.husky/pre-commit +4 -0
- package/.husky/pre-push +3 -0
- package/AGENTS.md +1 -1
- package/package.json +1 -1
- package/src/change-risk-profile.js +29 -7
- package/src/commit-guard.js +119 -0
- package/src/config-schema.js +2 -0
- package/src/decision-distill.js +32 -25
- package/src/diagnostic-codes.js +95 -0
- package/src/doctor-diagnostics.js +170 -1
- package/src/fr-index.js +359 -0
- package/src/knowledge-stats.js +99 -2
- package/src/machine-interface.js +37 -0
- package/src/run/archive-distill.js +29 -0
- package/src/run/prompt.js +32 -1
- package/src/run/verify-quality-scan.js +151 -5
- package/src/stage-contract.js +496 -19
- package/src/stage-review-checklist.js +13 -1
- package/src/stages/brainstorm.js +6 -1
- package/src/stages/verify.js +6 -2
- package/src/verify-facts-schema.js +21 -5
- package/src/verify-postcheck.js +86 -2
- package/src/verify-probes.js +1293 -27
package/src/fr-index.js
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fr-index.js — FR 稳定索引核心(L1:稳定 id + knowledge 索引 + 取代标记)
|
|
3
|
+
*
|
|
4
|
+
* 2026-09-18-fr-index-l1(三轮评审+战略定位定稿):requirements.md 归档后首次被语义消费——
|
|
5
|
+
* 全局稳定 id(FR-<域>-NNN,archive 时 CLI 发号)+ knowledge/fr/<域>.md 索引 + 承接引用驱动的
|
|
6
|
+
* 取代链。定位=**L3 的证据发生器**:四类遥测事件(fr-inject/fr-supersede/fr-duplicate-warning/
|
|
7
|
+
* fr-unreferenced,由消费方经 knowledge-hits.js 落盘)供 20-30 change 后裁决活规格真源——
|
|
8
|
+
* 含证伪出口(design 实验裁决条款:承接引用率趋零/链无人跟/重复靠人眼 ⇒ 可杀可冻 L3,索引降级检索面)。
|
|
9
|
+
*
|
|
10
|
+
* 纪律:
|
|
11
|
+
* - CLI 单一写入方:索引只在 indexRequirements 内部写(archive noAI 步调用);
|
|
12
|
+
* - 幂等键=全局 id(「来源变更」字段变更名命中即 no-op——同变更重跑零新增零漂移);
|
|
13
|
+
* - 承接 id 不存在 → warnings 收集不阻断(typo 不炸归档);
|
|
14
|
+
* - 未引用旧 FR 的删除/修改零判定(D-002 边界——undeclared deletion 是 L3 合并门禁领地,
|
|
15
|
+
* L1 只经 unreferenced 计数采观察信号,显式「不算 L3 门禁」);
|
|
16
|
+
* - 域文件读写复用 decision-distill 参数化底座(splitKnowledgeSections/joinKnowledgeFile/
|
|
17
|
+
* syncIndexRoutingLines/discoverModuleIndex),不复制实现。
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'fs';
|
|
21
|
+
import { join } from 'path';
|
|
22
|
+
import {
|
|
23
|
+
splitKnowledgeSections,
|
|
24
|
+
joinKnowledgeFile,
|
|
25
|
+
syncIndexRoutingLines,
|
|
26
|
+
discoverModuleIndex,
|
|
27
|
+
} from './decision-distill.js';
|
|
28
|
+
|
|
29
|
+
/** epoch:此前归档不检查/不索引(存量 93 份不回填——不伪造历史,D-007)。 */
|
|
30
|
+
export const FR_INDEX_EPOCH = '2026-09-18';
|
|
31
|
+
|
|
32
|
+
const FR_DIR = 'fr';
|
|
33
|
+
/** FR 节头:## FR-<域>-NNN 标题(域=[a-z0-9-] **含连字符**——真实模块 id 多为 cli-entry/core-engine 等连字符形态,
|
|
34
|
+
* R1 审查阻断①实证:漏 - 会让连字符域的发号/幂等/解析/承接全断。第二组为非捕获可选占位,
|
|
35
|
+
* 三组结构与底座 splitKnowledgeSections 的 decisions 正则对齐(m[3]=标题,勿改组序)。 */
|
|
36
|
+
const FR_SECTION_RE = /^## (FR-[a-z0-9-]+-\d+)(?:@v(\d+))?\s*(.*)$/;
|
|
37
|
+
const FR_GLOBAL_ID_RE = /^FR-[a-z0-9-]+-\d+$/;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 解析变更 requirements.md 的 FR 块。
|
|
41
|
+
* 形态(brainstorm step8 指引):`### FR-NN: 标题` + 可选 `承接: FR-<域>-NNN[, ...]` 行 +
|
|
42
|
+
* Given/When/Then 块(场景名取 `#### 场景:X` 或 `**场景:X**` 行;无场景名的 GWT 块按序号占位)。
|
|
43
|
+
* @returns {{ missing: boolean, frs: Array<{local:string,title:string,supersedes:string[],scenarios:string[]}>, malformed: string[] }}
|
|
44
|
+
*/
|
|
45
|
+
export function parseChangeRequirements(changeDir) {
|
|
46
|
+
const p = join(changeDir, 'requirements.md');
|
|
47
|
+
if (!existsSync(p)) return { missing: true, frs: [], malformed: [] };
|
|
48
|
+
let content;
|
|
49
|
+
try {
|
|
50
|
+
content = readFileSync(p, 'utf8');
|
|
51
|
+
} catch {
|
|
52
|
+
return { missing: true, frs: [], malformed: [] };
|
|
53
|
+
}
|
|
54
|
+
const frs = [];
|
|
55
|
+
const malformed = [];
|
|
56
|
+
const lines = content.replace(/\r\n/g, '\n').split('\n');
|
|
57
|
+
let cur = null;
|
|
58
|
+
for (const line of lines) {
|
|
59
|
+
const h = line.match(/^###\s+(FR-\d+)\s*[::]\s*(.*)$/);
|
|
60
|
+
if (h) {
|
|
61
|
+
if (cur) frs.push(cur);
|
|
62
|
+
cur = { local: h[1], title: (h[2] || '').trim(), supersedes: [], scenarios: [] };
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (!cur) continue;
|
|
66
|
+
const s = line.match(/^\s*承接\s*[::]\s*(.+)$/);
|
|
67
|
+
if (s) {
|
|
68
|
+
for (const tok of s[1].split(/[,,、]/)) {
|
|
69
|
+
const id = tok.trim();
|
|
70
|
+
if (!id) continue;
|
|
71
|
+
if (FR_GLOBAL_ID_RE.test(id)) cur.supersedes.push(id);
|
|
72
|
+
else malformed.push(`${cur.local} 的承接行含非全局 id 形态「${id}」(应为 FR-<域>-NNN)`);
|
|
73
|
+
}
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const sc = line.match(/^\s*(?:#{2,4}\s*场景\s*[::]\s*(.+)|\*\*场景\s*[::]\s*([^*]+)\*\*)\s*$/);
|
|
77
|
+
if (sc) {
|
|
78
|
+
const name = ((sc[1] || sc[2] || '')).trim();
|
|
79
|
+
cur.scenarios.push(name || `场景${cur.scenarios.length + 1}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (cur) frs.push(cur);
|
|
83
|
+
return { missing: false, frs, malformed };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 域解析(导出供注入/软门消费):变更自身 design.md 文件变更清单表行(剥 NEW: 前缀)× moduleIndex paths 前缀匹配;无匹配 → unmapped。 */
|
|
87
|
+
export function resolveTouchedDomains(changeDir, moduleIndex) {
|
|
88
|
+
const domains = new Set();
|
|
89
|
+
const designPath = join(changeDir, 'design.md');
|
|
90
|
+
if (moduleIndex && existsSync(designPath)) {
|
|
91
|
+
let design;
|
|
92
|
+
try {
|
|
93
|
+
design = readFileSync(designPath, 'utf8');
|
|
94
|
+
} catch {
|
|
95
|
+
design = '';
|
|
96
|
+
}
|
|
97
|
+
for (const line of design.replace(/\r\n/g, '\n').split('\n')) {
|
|
98
|
+
const m = line.match(/^\|\s*(?:新增|修改|删除)\s*\|\s*(?:NEW:)?([^\s|]+)\s*\|/);
|
|
99
|
+
if (!m) continue;
|
|
100
|
+
const filePath = m[1].trim();
|
|
101
|
+
for (const [modId, mod] of Object.entries(moduleIndex)) {
|
|
102
|
+
const paths = (mod && Array.isArray(mod.paths)) ? mod.paths : [];
|
|
103
|
+
if (paths.some((pp) => filePath === pp || filePath.startsWith(pp.endsWith('/') ? pp : pp + '/'))) {
|
|
104
|
+
domains.add(modId);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (domains.size === 0) domains.add('unmapped');
|
|
110
|
+
return [...domains];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** 域文件路径与域索引读写(复用参数化底座;FR 节无版本段,id 恒等)。 */
|
|
114
|
+
function frDirPath(knowledgeRoot) {
|
|
115
|
+
return join(knowledgeRoot, FR_DIR);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function loadDomainSections(knowledgeRoot, domain) {
|
|
119
|
+
const p = join(frDirPath(knowledgeRoot), `${domain}.md`);
|
|
120
|
+
if (!existsSync(p)) {
|
|
121
|
+
return {
|
|
122
|
+
preamble: [
|
|
123
|
+
'---',
|
|
124
|
+
`author: sillyspec-fr-index`,
|
|
125
|
+
`created_at: ${new Date().toISOString()}`,
|
|
126
|
+
'---',
|
|
127
|
+
'',
|
|
128
|
+
`# FR 索引 — ${domain}`,
|
|
129
|
+
'',
|
|
130
|
+
'> fr-index 从归档变更 requirements.md 幂等提炼(「最近确认」= 归档时 HEAD)。条目字段行为机械解析契约,勿手改。',
|
|
131
|
+
'> superseded 条目保留供取代链回溯;brainstorm 注入默认只给 active。',
|
|
132
|
+
'',
|
|
133
|
+
],
|
|
134
|
+
sections: [],
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
return splitKnowledgeSections(readFileSync(p, 'utf8'), {
|
|
139
|
+
sectionRegex: FR_SECTION_RE,
|
|
140
|
+
buildId: (n) => n,
|
|
141
|
+
});
|
|
142
|
+
} catch {
|
|
143
|
+
return { preamble: [], sections: [] };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function renderFrLines(entry, headHash) {
|
|
148
|
+
const lines = [`## ${entry.id} ${entry.title}`];
|
|
149
|
+
// 归属行键用「变更:」——与 decision-distill splitKnowledgeSections 的机械解析契约对齐
|
|
150
|
+
// (design 接口定义示例中的「来源变更」即本字段,语义等价,取解析器认的字面)
|
|
151
|
+
lines.push(`变更:${entry.change}`);
|
|
152
|
+
lines.push(`状态:${entry.supersededBy ? 'superseded' : 'active'}`);
|
|
153
|
+
if (entry.supersededBy) lines.push(`superseded_by:${entry.supersededBy}`);
|
|
154
|
+
if (entry.supersededOf) lines.push(`取代链:${entry.supersededOf} ← 本条目(${entry.change} 承接)`);
|
|
155
|
+
lines.push(`摘要:${(entry.scenarios || []).slice(0, 5).join(';') || '(无场景名)'}`);
|
|
156
|
+
lines.push(`最近确认:${headHash || ''}`);
|
|
157
|
+
return lines;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** 全域条目扫描:domain → sections(供发号取 max、承接翻链、在场性检查)。 */
|
|
161
|
+
function scanAllDomains(knowledgeRoot) {
|
|
162
|
+
const out = new Map();
|
|
163
|
+
try {
|
|
164
|
+
for (const f of readdirSync(frDirPath(knowledgeRoot))) {
|
|
165
|
+
if (!f.endsWith('.md')) continue;
|
|
166
|
+
const domain = f.replace(/\.md$/, '');
|
|
167
|
+
out.set(domain, loadDomainSections(knowledgeRoot, domain));
|
|
168
|
+
}
|
|
169
|
+
} catch { /* 目录不存在 → 空索引 */ }
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function nextIdForDomain(domain, allSections) {
|
|
174
|
+
let max = 0;
|
|
175
|
+
const re = new RegExp(`^FR-${domain.replace(/[-]/g, '-')}-(\\d+)$`);
|
|
176
|
+
for (const st of allSections.values()) {
|
|
177
|
+
for (const s of st.sections) {
|
|
178
|
+
const m = (s.number || '').match(re);
|
|
179
|
+
if (m) max = Math.max(max, parseInt(m[1], 10));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return `FR-${domain}-${String(max + 1).padStart(3, '0')}`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* 归档索引主入口(幂等):解析 → 域解析 → 发号 → 承接翻链 → 写域文件 → INDEX 路由。
|
|
187
|
+
* @param {{ changeDir: string, knowledgeRoot: string, headHash?: string, cwd?: string }} args
|
|
188
|
+
* @returns {{ skipped?: string, written: Array<{file,id,action}>, superseded: Array<{from,to,change}>, unreferenced: Array<{domain,count}>, warnings: string[] }}
|
|
189
|
+
*/
|
|
190
|
+
export function indexRequirements({ changeDir, knowledgeRoot, headHash = '' }) {
|
|
191
|
+
const changeName = changeDir.split(/[\\/]/).pop();
|
|
192
|
+
const parsed = parseChangeRequirements(changeDir);
|
|
193
|
+
if (parsed.missing) {
|
|
194
|
+
return { skipped: 'requirements.md 不存在(quick/scale:small 无索引义务)', written: [], superseded: [], unreferenced: [], warnings: [] };
|
|
195
|
+
}
|
|
196
|
+
if (parsed.frs.length === 0) {
|
|
197
|
+
return { skipped: 'requirements.md 无 FR 块', written: [], superseded: [], unreferenced: [], warnings: [] };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const warnings = [...parsed.malformed];
|
|
201
|
+
const moduleIndex = discoverModuleIndex(knowledgeRoot);
|
|
202
|
+
const domains = resolveTouchedDomains(changeDir, moduleIndex);
|
|
203
|
+
const all = scanAllDomains(knowledgeRoot);
|
|
204
|
+
|
|
205
|
+
// 幂等闸门:任一域文件已有本变更「来源变更」条目 → 全量 no-op(同变更重跑零新增零漂移)
|
|
206
|
+
for (const st of all.values()) {
|
|
207
|
+
if (st.sections.some((s) => s.change === changeName)) {
|
|
208
|
+
return { written: [], superseded: [], unreferenced: [], warnings };
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// 承接校验:引用的全局 id 必须在现有索引中存在(typo → warn 不阻断)
|
|
213
|
+
const knownIds = new Set();
|
|
214
|
+
for (const st of all.values()) for (const s of st.sections) knownIds.add(s.number);
|
|
215
|
+
const refIds = new Set();
|
|
216
|
+
for (const fr of parsed.frs) for (const id of fr.supersedes) refIds.add(id);
|
|
217
|
+
for (const id of refIds) {
|
|
218
|
+
if (!knownIds.has(id)) warnings.push(`承接引用的 ${id} 不在索引中(域拼错或号不存在)——该引用不翻链,请核对`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// 发号 + 写入(每 FR 落其首个触达域;多域变更按域去重逐条落)
|
|
222
|
+
const written = [];
|
|
223
|
+
const superseded = [];
|
|
224
|
+
const perDomainCounter = new Map();
|
|
225
|
+
const ensureDomain = (domain) => {
|
|
226
|
+
if (!all.has(domain)) all.set(domain, loadDomainSections(knowledgeRoot, domain));
|
|
227
|
+
return all.get(domain);
|
|
228
|
+
};
|
|
229
|
+
// 发号与翻链:每条 FR 落**首个触达域**(primary domain)取一个全局 id——幂等键=全局 id 单一身份
|
|
230
|
+
// (R1 审查缺口:per-domain × per-FR 会给同一 FR 发 N 个 id,违背 D-001 单一身份);
|
|
231
|
+
// 承接翻链可跨全域命中目标条目(新条目在 primary 域,旧条目可在任何域)。
|
|
232
|
+
const dirtyDomains = new Set();
|
|
233
|
+
const primaryDomain = domains[0];
|
|
234
|
+
const st = ensureDomain(primaryDomain);
|
|
235
|
+
for (const fr of parsed.frs) {
|
|
236
|
+
const id = nextIdForDomain(primaryDomain, all);
|
|
237
|
+
perDomainCounter.set(primaryDomain, (perDomainCounter.get(primaryDomain) || 0) + 1);
|
|
238
|
+
st.sections.push({
|
|
239
|
+
number: id,
|
|
240
|
+
version: NaN,
|
|
241
|
+
id,
|
|
242
|
+
title: fr.title,
|
|
243
|
+
change: changeName,
|
|
244
|
+
lines: renderFrLines({ id, title: fr.title, change: changeName, scenarios: fr.scenarios }, headHash),
|
|
245
|
+
});
|
|
246
|
+
dirtyDomains.add(primaryDomain);
|
|
247
|
+
written.push({ file: `${FR_DIR}/${primaryDomain}.md`, id, action: 'added' });
|
|
248
|
+
// 本 FR 的承接引用 → 翻旧条目(跨全域扫描目标段;就地补丁不动原摘要——R1 审查缺口:整段重写会抹掉摘要)
|
|
249
|
+
for (const refId of fr.supersedes) {
|
|
250
|
+
if (!knownIds.has(refId)) continue;
|
|
251
|
+
for (const [st2Domain, st2] of all.entries()) {
|
|
252
|
+
const target = st2.sections.find((s) => s.number === refId && !s.lines.some((l) => l.startsWith('superseded_by:')));
|
|
253
|
+
if (!target) continue;
|
|
254
|
+
// 就地补丁:状态翻 superseded + 插 superseded_by + 追取代链注记 + 刷新最近确认(摘要/标题保留)
|
|
255
|
+
target.lines = target.lines
|
|
256
|
+
.map((l) => (l.startsWith('状态:') ? `状态:superseded` : l.startsWith('最近确认:') ? `最近确认:${headHash || ''}` : l))
|
|
257
|
+
.filter((l) => !l.startsWith('superseded_by:') && !l.startsWith('取代链:'));
|
|
258
|
+
const stateIdx = target.lines.findIndex((l) => l.startsWith('状态:'));
|
|
259
|
+
target.lines.splice(stateIdx + 1, 0, `superseded_by:${id}`, `取代链:${refId} ← ${id}(${changeName} 承接)`);
|
|
260
|
+
dirtyDomains.add(st2Domain);
|
|
261
|
+
superseded.push({ from: refId, to: id, change: changeName });
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// unreferenced 探针(D-008 护栏③):触达域 active 条目未被本次承接引用的计数——观察信号,不算 L3 门禁
|
|
267
|
+
const unreferenced = [];
|
|
268
|
+
for (const domain of domains) {
|
|
269
|
+
const stx = all.get(domain);
|
|
270
|
+
if (!stx) continue;
|
|
271
|
+
const activeIds = stx.sections
|
|
272
|
+
.filter((s) => !s.lines.some((l) => l.startsWith('superseded_by:')))
|
|
273
|
+
.map((s) => s.number)
|
|
274
|
+
.filter((n) => n && !written.some((w) => w.id === n));
|
|
275
|
+
const count = activeIds.filter((id) => !refIds.has(id)).length;
|
|
276
|
+
if (count > 0) unreferenced.push({ domain, count });
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// 落盘(dirty 集=新条目域 ∪ 翻链命中域——R1 审查阻断②:漏翻链域会返回值谎报成功但盘上丢失)
|
|
280
|
+
mkdirSync(frDirPath(knowledgeRoot), { recursive: true });
|
|
281
|
+
for (const d of dirtyDomains) {
|
|
282
|
+
const stx = all.get(d);
|
|
283
|
+
writeFileSync(join(frDirPath(knowledgeRoot), `${d}.md`), joinKnowledgeFile(stx.preamble, stx.sections));
|
|
284
|
+
}
|
|
285
|
+
if (written.length > 0 || superseded.length > 0) {
|
|
286
|
+
syncIndexRoutingLines(knowledgeRoot, {
|
|
287
|
+
section: 'FR 需求索引',
|
|
288
|
+
subdir: FR_DIR,
|
|
289
|
+
makeLine: (d) => `- ${d}|FR|需求|承接 → [${FR_DIR}/${d}.md](${FR_DIR}/${d}.md)`,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return { written, superseded, unreferenced, warnings };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* 全量索引扫描(doctor D14 第四检查消费):全部域的全部条目(含 superseded)拍平。
|
|
298
|
+
* @returns {Array<{domain,id,title,change,supersededBy:string|null}>} fr/ 目录不存在 → []
|
|
299
|
+
*/
|
|
300
|
+
export function scanFrIndex(knowledgeRoot) {
|
|
301
|
+
const out = [];
|
|
302
|
+
let files = [];
|
|
303
|
+
try {
|
|
304
|
+
files = readdirSync(frDirPath(knowledgeRoot)).filter((f) => f.endsWith('.md'));
|
|
305
|
+
} catch {
|
|
306
|
+
return out;
|
|
307
|
+
}
|
|
308
|
+
for (const f of files) {
|
|
309
|
+
const domain = f.replace(/\.md$/, '');
|
|
310
|
+
const st = loadDomainSections(knowledgeRoot, domain);
|
|
311
|
+
for (const s of st.sections) {
|
|
312
|
+
const sup = s.lines.find((l) => l.startsWith('superseded_by:'));
|
|
313
|
+
out.push({
|
|
314
|
+
domain,
|
|
315
|
+
id: s.number,
|
|
316
|
+
title: s.title || '',
|
|
317
|
+
change: s.change || '',
|
|
318
|
+
supersededBy: sup ? sup.replace(/^superseded_by:\s*/, '').trim() : null,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return out;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* 注入源:触达域的 active 条目(superseded 默认藏——D-004)。
|
|
327
|
+
* @returns {Array<{domain,id,title,change,scenarios:string[]}>}
|
|
328
|
+
*/
|
|
329
|
+
export function readActiveFrDigest(knowledgeRoot, domains) {
|
|
330
|
+
const out = [];
|
|
331
|
+
for (const domain of domains || []) {
|
|
332
|
+
const st = loadDomainSections(knowledgeRoot, domain);
|
|
333
|
+
for (const s of st.sections) {
|
|
334
|
+
if (s.lines.some((l) => l.startsWith('superseded_by:'))) continue;
|
|
335
|
+
const scenarioLine = s.lines.find((l) => l.startsWith('摘要:'));
|
|
336
|
+
const scenarios = scenarioLine ? scenarioLine.replace(/^摘要:\s*/, '').split(';').map((x) => x.trim()).filter(Boolean) : [];
|
|
337
|
+
out.push({ domain, id: s.number, title: s.title || '', change: s.change || '', scenarios });
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return out;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* 标题 bigram 重叠率(中英混排零依赖):字符 bigram 集合 |A∩B| / |A∪B|。
|
|
345
|
+
*/
|
|
346
|
+
export function frTitleOverlap(a, b) {
|
|
347
|
+
const grams = (s) => {
|
|
348
|
+
const t = String(s || '').replace(/\s+/g, '');
|
|
349
|
+
const set = new Set();
|
|
350
|
+
for (let i = 0; i < t.length - 1; i++) set.add(t.slice(i, i + 2));
|
|
351
|
+
return set;
|
|
352
|
+
};
|
|
353
|
+
const A = grams(a);
|
|
354
|
+
const B = grams(b);
|
|
355
|
+
if (A.size === 0 || B.size === 0) return 0;
|
|
356
|
+
let inter = 0;
|
|
357
|
+
for (const g of A) if (B.has(g)) inter++;
|
|
358
|
+
return inter / (A.size + B.size - inter);
|
|
359
|
+
}
|
package/src/knowledge-stats.js
CHANGED
|
@@ -23,10 +23,11 @@
|
|
|
23
23
|
* 「窗口内无命中」两种空矩阵——后者死重对照无意义,人类可读模式同样略过 neverHit 段)。
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
-
import { existsSync } from 'fs'
|
|
26
|
+
import { existsSync, readdirSync } from 'fs'
|
|
27
27
|
import { join } from 'path'
|
|
28
28
|
import { readKnowledgeHits } from './knowledge-hits.js'
|
|
29
29
|
import { parseKnowledgeIndex } from './knowledge-match.js'
|
|
30
|
+
import { scanFrIndex } from './fr-index.js'
|
|
30
31
|
|
|
31
32
|
// ── 聚合核心 ──
|
|
32
33
|
|
|
@@ -95,6 +96,85 @@ export function buildHitMatrix(knowledgeDir, runtimeDir, { sinceDays = 30 } = {}
|
|
|
95
96
|
return { matrix, neverHit, totalInjects, totalClassifies }
|
|
96
97
|
}
|
|
97
98
|
|
|
99
|
+
// ── FR 索引实验聚合(2026-09-18-fr-index-l1 后续钩子·L3 裁决仪表盘)──
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 聚合 L1 四类 fr-* 遥测事件 + knowledge/fr/ 索引面统计——L3 裁决(design 实验证伪条款)
|
|
103
|
+
* 的出数面:观察期满一眼读出 注入命中/取代链跟随/重复拦截/删除缺口信号 与 承接引用率。
|
|
104
|
+
*
|
|
105
|
+
* 口径:
|
|
106
|
+
* - 事件面(jsonl,sinceDays 窗口):fr-inject 计数+涉及变更数;fr-supersede 计数+涉及变更数;
|
|
107
|
+
* fr-duplicate-warning 计数+命中候选数;fr-unreferenced 按域聚合 count 总和;
|
|
108
|
+
* - 索引面(knowledge/fr/*.md,全量——索引是累积账本非窗口流):条目/active/superseded 计数、
|
|
109
|
+
* 来源变更数(承接引用率分母——事件流无 fr-added 事件,分母从索引读,不加归档管线事件);
|
|
110
|
+
* - 承接引用率 = fr-supersede 涉及变更数 / 索引来源变更数(分母 0 → null)。
|
|
111
|
+
* 注意分子是 sinceDays 窗口内、分母是全量——窗口≠全量时比率偏低是口径事实,
|
|
112
|
+
* 裁决时用大窗口(--since-days 9999)读全口径。
|
|
113
|
+
*
|
|
114
|
+
* @returns {{ present: boolean, events: {...}, index: {...}, supersedeRate: number|null }}
|
|
115
|
+
* present=false(无 fr/ 目录且零 fr-* 事件)→ 其余字段零值,消费方可整体略过。
|
|
116
|
+
*/
|
|
117
|
+
export function buildFrIndexStats(knowledgeDir, runtimeDir, { sinceDays = 30 } = {}) {
|
|
118
|
+
const records = readKnowledgeHits(runtimeDir, { sinceDays })
|
|
119
|
+
const frEvents = records.filter((r) => typeof r.type === 'string' && r.type.startsWith('fr-'))
|
|
120
|
+
|
|
121
|
+
const injectChanges = new Set()
|
|
122
|
+
const supersedeChanges = new Set()
|
|
123
|
+
const duplicateCandidates = new Set()
|
|
124
|
+
const unreferencedByDomain = new Map()
|
|
125
|
+
let frInject = 0
|
|
126
|
+
let frSupersede = 0
|
|
127
|
+
let frDuplicateWarning = 0
|
|
128
|
+
for (const r of frEvents) {
|
|
129
|
+
if (r.type === 'fr-inject') {
|
|
130
|
+
frInject += 1
|
|
131
|
+
if (r.change) injectChanges.add(r.change)
|
|
132
|
+
} else if (r.type === 'fr-supersede') {
|
|
133
|
+
frSupersede += 1
|
|
134
|
+
if (r.change) supersedeChanges.add(r.change)
|
|
135
|
+
} else if (r.type === 'fr-duplicate-warning') {
|
|
136
|
+
frDuplicateWarning += 1
|
|
137
|
+
if (r.candidate) duplicateCandidates.add(r.candidate)
|
|
138
|
+
} else if (r.type === 'fr-unreferenced') {
|
|
139
|
+
const domain = r.domain || 'unknown'
|
|
140
|
+
const row = unreferencedByDomain.get(domain) || { domain, events: 0, count: 0 }
|
|
141
|
+
row.events += 1
|
|
142
|
+
row.count += Number.isFinite(Number(r.count)) ? Number(r.count) : 0
|
|
143
|
+
unreferencedByDomain.set(domain, row)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const frDir = join(knowledgeDir, 'fr')
|
|
148
|
+
const frDirExists = existsSync(frDir)
|
|
149
|
+
const entries = frDirExists ? scanFrIndex(knowledgeDir) : []
|
|
150
|
+
const sourceChanges = new Set(entries.map((e) => e.change).filter(Boolean))
|
|
151
|
+
const supersededCount = entries.filter((e) => e.supersededBy).length
|
|
152
|
+
|
|
153
|
+
const denominator = sourceChanges.size
|
|
154
|
+
const supersedeRate = denominator > 0 ? supersedeChanges.size / denominator : null
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
present: frDirExists || frEvents.length > 0,
|
|
158
|
+
events: {
|
|
159
|
+
frInject,
|
|
160
|
+
frInjectChanges: injectChanges.size,
|
|
161
|
+
frSupersede,
|
|
162
|
+
frSupersedeChanges: supersedeChanges.size,
|
|
163
|
+
frDuplicateWarning,
|
|
164
|
+
frDuplicateCandidates: duplicateCandidates.size,
|
|
165
|
+
frUnreferenced: [...unreferencedByDomain.values()].sort((a, b) => b.count - a.count),
|
|
166
|
+
},
|
|
167
|
+
index: {
|
|
168
|
+
entries: entries.length,
|
|
169
|
+
active: entries.length - supersededCount,
|
|
170
|
+
superseded: supersededCount,
|
|
171
|
+
sourceChanges: denominator,
|
|
172
|
+
domains: frDirExists ? readdirSync(frDir).filter((f) => f.endsWith('.md')).map((f) => f.replace(/\.md$/, '')) : [],
|
|
173
|
+
},
|
|
174
|
+
supersedeRate,
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
98
178
|
// ── CLI 入口(形态对齐 src/stages/knowledge.js cmdSearch / src/knowledge-classify.js cmdKnowledgeClassify)──
|
|
99
179
|
|
|
100
180
|
function outputJson(ok, data, error) {
|
|
@@ -139,9 +219,10 @@ export async function cmdKnowledgeStats(dir, args, opts = {}) {
|
|
|
139
219
|
// 遥测在场判定用全量读取(不加窗口):文件缺失/只有坏行 → 无遥测
|
|
140
220
|
const hasTelemetry = readKnowledgeHits(runtimeDir).length > 0
|
|
141
221
|
const result = buildHitMatrix(knowledgeDir, runtimeDir, { sinceDays })
|
|
222
|
+
const frIndex = buildFrIndexStats(knowledgeDir, runtimeDir, { sinceDays })
|
|
142
223
|
|
|
143
224
|
if (asJson) {
|
|
144
|
-
outputJson(true, { sinceDays, hasTelemetry, ...result })
|
|
225
|
+
outputJson(true, { sinceDays, hasTelemetry, ...result, frIndex })
|
|
145
226
|
return
|
|
146
227
|
}
|
|
147
228
|
|
|
@@ -176,5 +257,21 @@ export async function cmdKnowledgeStats(dir, args, opts = {}) {
|
|
|
176
257
|
}
|
|
177
258
|
|
|
178
259
|
lines.push(`遥测计数(窗口内):注入 ${result.totalInjects} 次 | 归类 ${result.totalClassifies} 次`)
|
|
260
|
+
|
|
261
|
+
// FR 索引实验(L3 裁决仪表盘——证伪条款出数面;裁决时建议 --since-days 大窗口读全口径)
|
|
262
|
+
if (frIndex.present) {
|
|
263
|
+
const e = frIndex.events
|
|
264
|
+
const i = frIndex.index
|
|
265
|
+
lines.push('')
|
|
266
|
+
lines.push(`🔬 FR 索引实验(窗口内事件 / 索引面全量——L3 裁决指标,证伪条款见 2026-09-18-fr-index-l1 design)`)
|
|
267
|
+
lines.push(` 注入 fr-inject ${e.frInject} 次(${e.frInjectChanges} 变更) | 取代链 fr-supersede ${e.frSupersede} 次(${e.frSupersedeChanges} 变更) | 重复拦截 fr-duplicate-warning ${e.frDuplicateWarning} 次(${e.frDuplicateCandidates} 候选)`)
|
|
268
|
+
if (e.frUnreferenced.length > 0) {
|
|
269
|
+
const top = e.frUnreferenced.slice(0, 5).map((u) => `${u.domain}×${u.count}`).join(',')
|
|
270
|
+
lines.push(` 删除缺口信号 fr-unreferenced:${top}${e.frUnreferenced.length > 5 ? ' …' : ''}(观察信号·不算 L3 门禁)`)
|
|
271
|
+
}
|
|
272
|
+
lines.push(` 索引面:${i.entries} 条(active ${i.active} / superseded ${i.superseded})| 来源变更 ${i.sourceChanges} 个 | 域 ${i.domains.join(', ') || '—'}`)
|
|
273
|
+
lines.push(` 承接引用率:${frIndex.supersedeRate === null ? '—(索引空)' : `${(frIndex.supersedeRate * 100).toFixed(0)}%(${e.frSupersedeChanges}/${i.sourceChanges},分子窗口内/分母全量——裁决用大窗口)`}`)
|
|
274
|
+
}
|
|
275
|
+
|
|
179
276
|
console.log(lines.join('\n'))
|
|
180
277
|
}
|