@zhuan-ai/zhuanspec 2.16.6 → 2.17.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/dist/cli/hooks.js +10 -0
- package/dist/cli/index.js +4 -0
- package/dist/commands/knowledge.d.ts +12 -2
- package/dist/commands/knowledge.js +277 -3
- package/dist/core/hooks/context-load-hook.js +2 -0
- package/dist/core/hooks/knowledge-index.d.ts +54 -2
- package/dist/core/hooks/knowledge-index.js +247 -11
- package/dist/core/hooks/knowledge-usage.d.ts +61 -0
- package/dist/core/hooks/knowledge-usage.js +192 -0
- package/dist/core/hooks/post-archive.js +128 -29
- package/dist/core/hooks/review-hooks.js +198 -43
- package/dist/core/hooks/summarize.js +93 -1
- package/dist/core/hooks/user-input-hook.d.ts +12 -0
- package/dist/core/hooks/user-input-hook.js +38 -0
- package/dist/core/init.js +10 -0
- package/dist/core/metrics/knowledge-funnel.d.ts +49 -0
- package/dist/core/metrics/knowledge-funnel.js +135 -0
- package/dist/core/templates/agents-template.d.ts +1 -1
- package/dist/core/templates/agents-template.js +29 -10
- package/dist/core/templates/codex-hooks-template.js +5 -0
- package/dist/core/templates/slash-command-templates.js +91 -12
- package/package.json +22 -20
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import path from 'path';
|
|
12
12
|
import { promises as fs } from 'fs';
|
|
13
|
+
import { parse as parseYaml } from 'yaml';
|
|
13
14
|
import { FileSystemUtils } from '../../utils/file-system.js';
|
|
14
15
|
export const KNOWLEDGE_CATEGORIES = [
|
|
15
16
|
'troubleshooting',
|
|
@@ -28,11 +29,16 @@ const SECTION_COMMENTS = {
|
|
|
28
29
|
'implicit-conventions': '<!-- 隐式约定 / 默认行为 / 团队惯例,由 post-archive hook 与 skill 共同管理 -->',
|
|
29
30
|
};
|
|
30
31
|
const ARCHIVE_LOG_HEADING = '## Archive Log';
|
|
32
|
+
/** 失效知识折叠区标题与说明 */
|
|
33
|
+
const DEPRECATED_HEADING = '## Deprecated';
|
|
34
|
+
const DEPRECATED_COMMENT = '<!-- 已被后续 change 推翻的失效知识,AI 不应引用;仅作审计留痕 -->';
|
|
31
35
|
const INDEX_HEADER = `# Knowledge Index
|
|
32
36
|
|
|
33
37
|
> 项目知识索引。AI 在进入任何阶段前应先浏览本文件,根据每条"适用场景"判断是否需要打开对应文件。
|
|
34
38
|
> 入口:所有写入由 \`@skill:zhuanspec:knowledge\` 或 post-archive hook 统一完成,禁止直接 Write/Edit 本目录。
|
|
35
|
-
> 索引漂移时运行 \`zhuanspec knowledge reindex --dry-run\`
|
|
39
|
+
> 索引漂移时运行 \`zhuanspec knowledge reindex --dry-run\` 查看差异。
|
|
40
|
+
> ⚠️ AI 只应引用三分区(Troubleshooting / Best Practices / Implicit Conventions)中的条目;
|
|
41
|
+
> \`## Deprecated\` 区为已失效知识,仅作审计留痕,**禁止引用**。`;
|
|
36
42
|
/**
|
|
37
43
|
* 生成默认的空 index.md 骨架(含三分区 + Archive Log)。
|
|
38
44
|
*/
|
|
@@ -47,7 +53,9 @@ export function buildInitialIndex() {
|
|
|
47
53
|
export function buildEntryLine(entry) {
|
|
48
54
|
const keywords = entry.keywords.trim() || '(未填写关键词)';
|
|
49
55
|
const useCase = entry.useCase.trim() || '(未填写适用场景)';
|
|
50
|
-
|
|
56
|
+
// high 严重度前置 🔴 标记,便于 AI 浏览索引时优先阅读
|
|
57
|
+
const sev = entry.severity === 'high' ? '🔴 ' : '';
|
|
58
|
+
return `- ${sev}[${entry.title}](${entry.category}/${entry.fileName}) — ${keywords} · 适用场景:${useCase}`;
|
|
51
59
|
}
|
|
52
60
|
/**
|
|
53
61
|
* 确保 index.md 含完整三分区骨架;若缺失分区则补齐(不覆盖已有条目与 Archive Log)。
|
|
@@ -146,13 +154,65 @@ function insertIntoSection(content, category, line) {
|
|
|
146
154
|
before.push(line, '');
|
|
147
155
|
return [...before, ...after].join('\n');
|
|
148
156
|
}
|
|
157
|
+
/** 匹配文件开头的 YAML frontmatter 块(`---` 包裹)。 */
|
|
158
|
+
const FRONT_MATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n|$)/;
|
|
149
159
|
/**
|
|
150
160
|
* 从一段 markdown 文本中解析知识条目 Front Matter。
|
|
151
|
-
*
|
|
161
|
+
*
|
|
162
|
+
* 两种格式自动识别:
|
|
163
|
+
* - **新版 YAML**(`---` 包裹):解析 id / title / keywords / trigger / severity / status,
|
|
164
|
+
* 适用场景优先取 `use_case` 字段,否则回退正文 `## 适用场景` 首行;缺 id 记 missingId。
|
|
165
|
+
* - **旧版散文**(无 frontmatter):沿用 `# 标题` + `**关键词**:` + `**适用场景**:`,
|
|
166
|
+
* 不参与 id 强校验,保证历史知识文件向后兼容。
|
|
152
167
|
*/
|
|
153
168
|
export function parseFrontMatter(markdown) {
|
|
154
|
-
const
|
|
155
|
-
|
|
169
|
+
const fmMatch = markdown.match(FRONT_MATTER_RE);
|
|
170
|
+
if (fmMatch) {
|
|
171
|
+
return parseYamlFrontMatter(fmMatch[1], markdown.slice(fmMatch[0].length));
|
|
172
|
+
}
|
|
173
|
+
return parseLegacyFrontMatter(markdown);
|
|
174
|
+
}
|
|
175
|
+
/** 解析新版 YAML frontmatter。 */
|
|
176
|
+
function parseYamlFrontMatter(yamlText, body) {
|
|
177
|
+
let data = {};
|
|
178
|
+
try {
|
|
179
|
+
const parsed = parseYaml(yamlText);
|
|
180
|
+
if (parsed && typeof parsed === 'object')
|
|
181
|
+
data = parsed;
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
// YAML 解析失败:当作缺字段处理,由上层 scan 记 error
|
|
185
|
+
}
|
|
186
|
+
// 判断是否为「知识库专用 YAML」:必须含 id 或 title(或 category/severity/trigger 等专属字段)。
|
|
187
|
+
// 仅含 name/description/type 等 memory 格式字段的旧文件降级为旧格式解析,避免误报强校验。
|
|
188
|
+
const knowledgeYamlKeys = ['id', 'title', 'category', 'severity', 'trigger', 'status', 'use_case', 'keywords'];
|
|
189
|
+
const isKnowledgeYaml = knowledgeYamlKeys.some((k) => k in data);
|
|
190
|
+
if (!isKnowledgeYaml) {
|
|
191
|
+
return parseLegacyFrontMatter(body ? `${yamlText}\n${body}` : body);
|
|
192
|
+
}
|
|
193
|
+
const trigger = (data.trigger && typeof data.trigger === 'object')
|
|
194
|
+
? data.trigger
|
|
195
|
+
: {};
|
|
196
|
+
const id = asString(data.id);
|
|
197
|
+
const title = asString(data.title) || pickHeadingTitle(body);
|
|
198
|
+
const keywords = normalizeKeywords(data.keywords ?? trigger.keywords);
|
|
199
|
+
const useCase = asString(data.use_case ?? data['适用场景']) || pickUseCaseFromBody(body);
|
|
200
|
+
return {
|
|
201
|
+
title,
|
|
202
|
+
keywords,
|
|
203
|
+
useCase,
|
|
204
|
+
missingUseCase: !useCase,
|
|
205
|
+
missingKeywords: !keywords,
|
|
206
|
+
isYaml: true,
|
|
207
|
+
id,
|
|
208
|
+
missingId: !id,
|
|
209
|
+
severity: normalizeSeverity(data.severity),
|
|
210
|
+
status: data.status === 'deprecated' ? 'deprecated' : 'active',
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
/** 解析旧版散文式 front matter(向后兼容)。 */
|
|
214
|
+
function parseLegacyFrontMatter(markdown) {
|
|
215
|
+
const title = pickHeadingTitle(markdown);
|
|
156
216
|
const pick = (label) => {
|
|
157
217
|
const re = new RegExp(`\\*\\*${escapeRegex(label)}\\*\\*\\s*[::]\\s*(.+?)\\s*$`, 'm');
|
|
158
218
|
const m = markdown.match(re);
|
|
@@ -166,8 +226,79 @@ export function parseFrontMatter(markdown) {
|
|
|
166
226
|
useCase,
|
|
167
227
|
missingUseCase: !useCase,
|
|
168
228
|
missingKeywords: !keywords,
|
|
229
|
+
isYaml: false,
|
|
230
|
+
id: '',
|
|
231
|
+
missingId: false, // 旧格式不参与 id 强校验
|
|
232
|
+
severity: 'medium',
|
|
233
|
+
status: 'active',
|
|
169
234
|
};
|
|
170
235
|
}
|
|
236
|
+
/**
|
|
237
|
+
* 从归档文档文本解析 deprecates 声明(哪些知识 id 被本次 change 推翻)。
|
|
238
|
+
* 支持两种来源(任一命中即收集,去重):
|
|
239
|
+
* 1. YAML frontmatter 顶层 `deprecates: bp-x, bp-y` 或数组形式
|
|
240
|
+
* 2. 正文行 `deprecates: bp-x, bp-y`(大小写不敏感,逗号/中文逗号/空格分隔)
|
|
241
|
+
* @returns 去重后的 id 列表
|
|
242
|
+
*/
|
|
243
|
+
export function parseDeprecatesDeclaration(markdown) {
|
|
244
|
+
const ids = new Set();
|
|
245
|
+
// 1. frontmatter 顶层 deprecates
|
|
246
|
+
const fmMatch = markdown.match(FRONT_MATTER_RE);
|
|
247
|
+
if (fmMatch) {
|
|
248
|
+
try {
|
|
249
|
+
const data = parseYaml(fmMatch[1]);
|
|
250
|
+
if (data && 'deprecates' in data) {
|
|
251
|
+
for (const id of splitDeprecatesValue(data.deprecates))
|
|
252
|
+
ids.add(id);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
// 解析失败忽略,继续走正文匹配
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
// 2. 正文行 `deprecates: ...`(行首锚定,避免误伤句中出现的字样)
|
|
260
|
+
const lineRe = /^deprecates\s*[::]\s*(.+)$/gim;
|
|
261
|
+
let m;
|
|
262
|
+
while ((m = lineRe.exec(markdown)) !== null) {
|
|
263
|
+
for (const id of splitDeprecatesValue(m[1]))
|
|
264
|
+
ids.add(id);
|
|
265
|
+
}
|
|
266
|
+
return Array.from(ids);
|
|
267
|
+
}
|
|
268
|
+
/** 将 deprecates 字段值(字符串/数组)拆成干净的 id 列表。 */
|
|
269
|
+
function splitDeprecatesValue(v) {
|
|
270
|
+
if (Array.isArray(v))
|
|
271
|
+
return v.map((x) => String(x).trim()).filter(Boolean);
|
|
272
|
+
if (typeof v === 'string')
|
|
273
|
+
return v.split(/[,,\s]+/).map((s) => s.trim()).filter(Boolean);
|
|
274
|
+
return [];
|
|
275
|
+
}
|
|
276
|
+
/** 取正文首个一级标题 `# xxx`。 */
|
|
277
|
+
function pickHeadingTitle(markdown) {
|
|
278
|
+
const m = markdown.match(/^#\s+(.+?)\s*$/m);
|
|
279
|
+
return m ? m[1].trim() : '';
|
|
280
|
+
}
|
|
281
|
+
/** 从正文 `## 适用场景` 段落抓首行非空文本。 */
|
|
282
|
+
function pickUseCaseFromBody(body) {
|
|
283
|
+
const m = body.match(/^##\s+适用场景\s*\r?\n+\s*([^\n]+?)\s*$/m);
|
|
284
|
+
return m ? m[1].trim() : '';
|
|
285
|
+
}
|
|
286
|
+
/** keywords 归一化:数组 → 逗号拼接;字符串 → 原样;其他 → 空串。 */
|
|
287
|
+
function normalizeKeywords(v) {
|
|
288
|
+
if (Array.isArray(v))
|
|
289
|
+
return v.map((x) => String(x).trim()).filter(Boolean).join(', ');
|
|
290
|
+
if (typeof v === 'string')
|
|
291
|
+
return v.trim();
|
|
292
|
+
return '';
|
|
293
|
+
}
|
|
294
|
+
/** severity 归一化:仅 high/low 生效,其余(含缺省/非法)一律 medium。 */
|
|
295
|
+
function normalizeSeverity(v) {
|
|
296
|
+
return v === 'high' || v === 'low' ? v : 'medium';
|
|
297
|
+
}
|
|
298
|
+
/** 安全转字符串(null/undefined → 空串)。 */
|
|
299
|
+
function asString(v) {
|
|
300
|
+
return v === null || v === undefined ? '' : String(v).trim();
|
|
301
|
+
}
|
|
171
302
|
/**
|
|
172
303
|
* 扫描 knowledge/{troubleshooting,best-practices,implicit-conventions}/*.md,
|
|
173
304
|
* 解析 Front Matter 组装 KnowledgeEntry 列表,遇缺字段记录 warning(不阻断)。
|
|
@@ -175,6 +306,7 @@ export function parseFrontMatter(markdown) {
|
|
|
175
306
|
export async function scanKnowledgeEntries(knowledgeDir) {
|
|
176
307
|
const entries = [];
|
|
177
308
|
const warnings = [];
|
|
309
|
+
const errors = [];
|
|
178
310
|
for (const category of KNOWLEDGE_CATEGORIES) {
|
|
179
311
|
const dir = path.join(knowledgeDir, category);
|
|
180
312
|
if (!(await FileSystemUtils.directoryExists(dir)))
|
|
@@ -198,14 +330,24 @@ export async function scanKnowledgeEntries(knowledgeDir) {
|
|
|
198
330
|
continue;
|
|
199
331
|
}
|
|
200
332
|
const fm = parseFrontMatter(text);
|
|
201
|
-
|
|
333
|
+
// 强校验仅作用于新版 YAML 文件:缺 id / title 记 error(身份缺失);
|
|
334
|
+
// 旧版散文文件一律走 warning 兜底,保证历史知识向后兼容。
|
|
335
|
+
if (fm.isYaml) {
|
|
336
|
+
if (fm.missingId) {
|
|
337
|
+
errors.push(`[ERROR] ${category}/${file} YAML frontmatter 缺少 id 字段(稳定主键,去重/合并依赖它)`);
|
|
338
|
+
}
|
|
339
|
+
if (!fm.title) {
|
|
340
|
+
errors.push(`[ERROR] ${category}/${file} YAML frontmatter 缺少 title 字段`);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
else if (!fm.title) {
|
|
202
344
|
warnings.push(`[WARN] ${category}/${file} 缺少一级标题 (# 标题),使用文件名兜底`);
|
|
203
345
|
}
|
|
204
346
|
if (fm.missingKeywords) {
|
|
205
|
-
warnings.push(`[WARN] ${category}/${file}
|
|
347
|
+
warnings.push(`[WARN] ${category}/${file} 缺少关键词(YAML keywords/trigger.keywords 或 **关键词** 字段)`);
|
|
206
348
|
}
|
|
207
349
|
if (fm.missingUseCase) {
|
|
208
|
-
warnings.push(`[WARN] ${category}/${file}
|
|
350
|
+
warnings.push(`[WARN] ${category}/${file} 缺少适用场景(YAML use_case / 正文 ## 适用场景 / **适用场景** 字段)`);
|
|
209
351
|
}
|
|
210
352
|
const firstKeyword = fm.keywords ? fm.keywords.split(/[,,]/)[0].trim() : '';
|
|
211
353
|
entries.push({
|
|
@@ -215,10 +357,87 @@ export async function scanKnowledgeEntries(knowledgeDir) {
|
|
|
215
357
|
keywords: fm.keywords,
|
|
216
358
|
// 兜底:适用场景缺失时用"首关键词相关任务"
|
|
217
359
|
useCase: fm.useCase || (firstKeyword ? `涉及 ${firstKeyword} 相关任务时` : '(未填写适用场景,待补齐)'),
|
|
360
|
+
id: fm.id || undefined,
|
|
361
|
+
severity: fm.isYaml ? fm.severity : undefined,
|
|
362
|
+
status: fm.isYaml ? fm.status : undefined,
|
|
218
363
|
});
|
|
219
364
|
}
|
|
220
365
|
}
|
|
221
|
-
return { entries, warnings };
|
|
366
|
+
return { entries, warnings, errors };
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* 在 knowledgeDir 内按 id 定位知识文件绝对路径(扫三类目录匹配 frontmatter.id)。
|
|
370
|
+
* 命中多个同 id 时返回首个(id 应唯一,重复属数据问题,由 scan 的 missingId 校验体系覆盖)。
|
|
371
|
+
*/
|
|
372
|
+
export async function findEntryFileById(knowledgeDir, id) {
|
|
373
|
+
if (!id)
|
|
374
|
+
return null;
|
|
375
|
+
for (const category of KNOWLEDGE_CATEGORIES) {
|
|
376
|
+
const dir = path.join(knowledgeDir, category);
|
|
377
|
+
if (!(await FileSystemUtils.directoryExists(dir)))
|
|
378
|
+
continue;
|
|
379
|
+
let files = [];
|
|
380
|
+
try {
|
|
381
|
+
files = (await fs.readdir(dir)).filter((f) => f.endsWith('.md') && !f.startsWith('.'));
|
|
382
|
+
}
|
|
383
|
+
catch {
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
for (const file of files) {
|
|
387
|
+
const full = path.join(dir, file);
|
|
388
|
+
try {
|
|
389
|
+
const fm = parseFrontMatter(await FileSystemUtils.readFile(full));
|
|
390
|
+
if (fm.isYaml && fm.id === id)
|
|
391
|
+
return full;
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
// 读失败跳过
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* 将指定知识文件的 YAML frontmatter status 改为目标值,保留正文与其他字段。
|
|
402
|
+
*
|
|
403
|
+
* 算法(不做整体 YAML 序列化,避免丢注释/改键序/改引号风格破坏文件):
|
|
404
|
+
* 1. FRONT_MATTER_RE 切出 yaml 块与正文 body;无 frontmatter(旧散文式)→ 返回 false,不动正文。
|
|
405
|
+
* 2. 仅当存在 `status:` 行时替换该行值,否则在 yaml 块末尾追加 `status: <status>`。
|
|
406
|
+
* 3. 改写后用 parseFrontMatter 复核 status 命中目标值再落盘(自检,防正则误伤)。
|
|
407
|
+
*
|
|
408
|
+
* @returns 是否实际写入(已是目标状态或旧散文式均返回 false)
|
|
409
|
+
*/
|
|
410
|
+
export async function setEntryStatus(filePath, status) {
|
|
411
|
+
let content;
|
|
412
|
+
try {
|
|
413
|
+
content = await FileSystemUtils.readFile(filePath);
|
|
414
|
+
}
|
|
415
|
+
catch {
|
|
416
|
+
return false;
|
|
417
|
+
}
|
|
418
|
+
const fmMatch = content.match(FRONT_MATTER_RE);
|
|
419
|
+
if (!fmMatch)
|
|
420
|
+
return false; // 旧散文式:绝不改正文
|
|
421
|
+
// 幂等:已是目标状态
|
|
422
|
+
const current = parseFrontMatter(content);
|
|
423
|
+
if (current.isYaml && current.status === status)
|
|
424
|
+
return false;
|
|
425
|
+
const yamlText = fmMatch[1];
|
|
426
|
+
const body = content.slice(fmMatch[0].length);
|
|
427
|
+
let newYaml;
|
|
428
|
+
if (/^status\s*:.*$/m.test(yamlText)) {
|
|
429
|
+
newYaml = yamlText.replace(/^status\s*:.*$/m, `status: ${status}`);
|
|
430
|
+
}
|
|
431
|
+
else {
|
|
432
|
+
newYaml = `${yamlText.replace(/\s+$/, '')}\nstatus: ${status}`;
|
|
433
|
+
}
|
|
434
|
+
const rebuilt = `---\n${newYaml}\n---\n${body}`;
|
|
435
|
+
// 自检:确认改写后能解析出目标 status,否则放弃(不落盘破坏文件)
|
|
436
|
+
const verify = parseFrontMatter(rebuilt);
|
|
437
|
+
if (!verify.isYaml || verify.status !== status)
|
|
438
|
+
return false;
|
|
439
|
+
await FileSystemUtils.writeFile(filePath, rebuilt);
|
|
440
|
+
return true;
|
|
222
441
|
}
|
|
223
442
|
/**
|
|
224
443
|
* 全量重建 index.md 的三大分区(保留 Archive Log 既有内容)。
|
|
@@ -237,13 +456,16 @@ export function rebuildIndexContent(scan, existingContent) {
|
|
|
237
456
|
archiveBlock = existingContent.slice(archiveIdx).trimEnd() + '\n';
|
|
238
457
|
}
|
|
239
458
|
}
|
|
240
|
-
// 2.
|
|
459
|
+
// 2. 拆分 active / deprecated(status===undefined 旧散文式按 active 处理)
|
|
460
|
+
const activeEntries = scan.entries.filter((e) => e.status !== 'deprecated');
|
|
461
|
+
const deprecatedEntries = scan.entries.filter((e) => e.status === 'deprecated');
|
|
462
|
+
// 3. active 条目按分类整理(保持扫描顺序 = 字典序)
|
|
241
463
|
const byCategory = {
|
|
242
464
|
'troubleshooting': [],
|
|
243
465
|
'best-practices': [],
|
|
244
466
|
'implicit-conventions': [],
|
|
245
467
|
};
|
|
246
|
-
for (const e of
|
|
468
|
+
for (const e of activeEntries)
|
|
247
469
|
byCategory[e.category].push(e);
|
|
248
470
|
const parts = [INDEX_HEADER, ''];
|
|
249
471
|
for (const cat of KNOWLEDGE_CATEGORIES) {
|
|
@@ -260,6 +482,20 @@ export function rebuildIndexContent(scan, existingContent) {
|
|
|
260
482
|
parts.push('');
|
|
261
483
|
}
|
|
262
484
|
}
|
|
485
|
+
// 4. 失效知识折叠区(仅在存在 deprecated 条目时渲染,用 <details> 默认收起)
|
|
486
|
+
if (deprecatedEntries.length > 0) {
|
|
487
|
+
parts.push(DEPRECATED_HEADING);
|
|
488
|
+
parts.push(DEPRECATED_COMMENT);
|
|
489
|
+
parts.push('');
|
|
490
|
+
parts.push('<details>');
|
|
491
|
+
parts.push(`<summary>已失效知识 ${deprecatedEntries.length} 条(点击展开,仅作审计留痕)</summary>`);
|
|
492
|
+
parts.push('');
|
|
493
|
+
for (const e of deprecatedEntries)
|
|
494
|
+
parts.push(buildEntryLine(e));
|
|
495
|
+
parts.push('');
|
|
496
|
+
parts.push('</details>');
|
|
497
|
+
parts.push('');
|
|
498
|
+
}
|
|
263
499
|
parts.push(archiveBlock.trimEnd());
|
|
264
500
|
parts.push('');
|
|
265
501
|
return parts.join('\n');
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Knowledge Usage Hook(引用上报)
|
|
3
|
+
*
|
|
4
|
+
* PostToolUse 旁路 hook:当 AI 通过 Read/Grep/Glob 命中 zhuanspec/knowledge/ 三类目录下的
|
|
5
|
+
* 知识文件时,往 zhuanspec/knowledge/.metrics/usage.json 累加一次「被引用」计数。
|
|
6
|
+
*
|
|
7
|
+
* 设计原则:
|
|
8
|
+
* - 纯旁路指标,**绝不阻断主流程、绝不让 hook 非零退出**(始终 continue:true / 0 退出)。
|
|
9
|
+
* - 仅认三类 category 子目录直属 `.md`,排除 `.pending/`、`.metrics/` 等非正式库文件。
|
|
10
|
+
* - 留一个清晰可复用的上报接口 reportKnowledgeRef,供 hook 与未来其它调用方共用。
|
|
11
|
+
*/
|
|
12
|
+
/** 单条引用计数明细 */
|
|
13
|
+
interface UsageCounter {
|
|
14
|
+
refCount: number;
|
|
15
|
+
lastRefAt: string;
|
|
16
|
+
}
|
|
17
|
+
/** usage.json 落点结构:zhuanspec/knowledge/.metrics/usage.json */
|
|
18
|
+
export interface KnowledgeUsageStats {
|
|
19
|
+
schemaVersion: 1;
|
|
20
|
+
totalRefs: number;
|
|
21
|
+
/** ProjectWiki Markdown 被引用总次数 */
|
|
22
|
+
projectWikiTotalRefs: number;
|
|
23
|
+
/** 键 = 知识条目稳定主键 id(如 bp-xxx);仅 YAML 文件有 id */
|
|
24
|
+
byId: Record<string, UsageCounter>;
|
|
25
|
+
/** 键 = 相对 knowledge/ 的路径,如 best-practices/xxx.md */
|
|
26
|
+
byFile: Record<string, UsageCounter>;
|
|
27
|
+
/** 键 = 相对 ZhuanSpec 根的 ProjectWiki 路径,如 service/.project-wiki/project.md */
|
|
28
|
+
byProjectWikiFile: Record<string, UsageCounter>;
|
|
29
|
+
lastUpdatedAt: string;
|
|
30
|
+
}
|
|
31
|
+
/** 单次上报输入(清晰可复用的上报接口契约) */
|
|
32
|
+
export interface KnowledgeRefEvent {
|
|
33
|
+
/** 被引用的知识文件路径(绝对或相对,函数内统一归一化) */
|
|
34
|
+
filePath: string;
|
|
35
|
+
/** 引用时间,缺省取 getBeijingTime() */
|
|
36
|
+
refAt?: string;
|
|
37
|
+
}
|
|
38
|
+
interface KnowledgeUsageOptions {
|
|
39
|
+
json?: boolean;
|
|
40
|
+
file?: string;
|
|
41
|
+
}
|
|
42
|
+
/** usage.json 绝对路径 */
|
|
43
|
+
export declare function getUsagePath(knowledgeDir: string): string;
|
|
44
|
+
/** 读取 usage.json,缺失/损坏均返回空结构(容错,不抛) */
|
|
45
|
+
export declare function readUsageStats(knowledgeDir: string): Promise<KnowledgeUsageStats>;
|
|
46
|
+
/**
|
|
47
|
+
* 清晰可复用的上报接口:累加一次引用。
|
|
48
|
+
* - filePath 未命中 knowledge/ 三类目录 → no-op 返回 false。
|
|
49
|
+
* - 命中则读 frontmatter 取 id(缺 id 仅按 byFile 计数),同步累加 byId / byFile / totalRefs。
|
|
50
|
+
* - 原子写回;写失败静默吞掉(旁路指标不得阻断主流程)。
|
|
51
|
+
*
|
|
52
|
+
* @returns 是否实际累加
|
|
53
|
+
*/
|
|
54
|
+
export declare function reportKnowledgeRef(knowledgeDir: string, event: KnowledgeRefEvent): Promise<boolean>;
|
|
55
|
+
/**
|
|
56
|
+
* PostToolUse hook 入口:从 stdin(tool_input.file_path) 或 --file 提取路径后上报。
|
|
57
|
+
* 始终输出 { continue: true } 信封并以 0 退出(旁路 hook)。
|
|
58
|
+
*/
|
|
59
|
+
export declare function knowledgeUsageHook(options: KnowledgeUsageOptions): Promise<void>;
|
|
60
|
+
export {};
|
|
61
|
+
//# sourceMappingURL=knowledge-usage.d.ts.map
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Knowledge Usage Hook(引用上报)
|
|
3
|
+
*
|
|
4
|
+
* PostToolUse 旁路 hook:当 AI 通过 Read/Grep/Glob 命中 zhuanspec/knowledge/ 三类目录下的
|
|
5
|
+
* 知识文件时,往 zhuanspec/knowledge/.metrics/usage.json 累加一次「被引用」计数。
|
|
6
|
+
*
|
|
7
|
+
* 设计原则:
|
|
8
|
+
* - 纯旁路指标,**绝不阻断主流程、绝不让 hook 非零退出**(始终 continue:true / 0 退出)。
|
|
9
|
+
* - 仅认三类 category 子目录直属 `.md`,排除 `.pending/`、`.metrics/` 等非正式库文件。
|
|
10
|
+
* - 留一个清晰可复用的上报接口 reportKnowledgeRef,供 hook 与未来其它调用方共用。
|
|
11
|
+
*/
|
|
12
|
+
import path from 'path';
|
|
13
|
+
import { FileSystemUtils } from '../../utils/file-system.js';
|
|
14
|
+
import { detectHookHost, sanitizeCodexEnvelope } from '../../utils/hook-host.js';
|
|
15
|
+
import { getBeijingTime, atomicWriteJson } from './record-progress.js';
|
|
16
|
+
import { KNOWLEDGE_CATEGORIES, parseFrontMatter } from './knowledge-index.js';
|
|
17
|
+
import { resolveZhuanSpecRoot } from '../../utils/resolve-root.js';
|
|
18
|
+
/** usage.json 绝对路径 */
|
|
19
|
+
export function getUsagePath(knowledgeDir) {
|
|
20
|
+
return path.join(knowledgeDir, '.metrics', 'usage.json');
|
|
21
|
+
}
|
|
22
|
+
/** 生成空 usage 结构 */
|
|
23
|
+
function emptyUsage() {
|
|
24
|
+
return {
|
|
25
|
+
schemaVersion: 1,
|
|
26
|
+
totalRefs: 0,
|
|
27
|
+
projectWikiTotalRefs: 0,
|
|
28
|
+
byId: {},
|
|
29
|
+
byFile: {},
|
|
30
|
+
byProjectWikiFile: {},
|
|
31
|
+
lastUpdatedAt: '',
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/** 读取 usage.json,缺失/损坏均返回空结构(容错,不抛) */
|
|
35
|
+
export async function readUsageStats(knowledgeDir) {
|
|
36
|
+
const usagePath = getUsagePath(knowledgeDir);
|
|
37
|
+
try {
|
|
38
|
+
if (!(await FileSystemUtils.fileExists(usagePath)))
|
|
39
|
+
return emptyUsage();
|
|
40
|
+
const raw = await FileSystemUtils.readFile(usagePath);
|
|
41
|
+
const parsed = JSON.parse(raw);
|
|
42
|
+
return {
|
|
43
|
+
schemaVersion: 1,
|
|
44
|
+
totalRefs: typeof parsed.totalRefs === 'number' ? parsed.totalRefs : 0,
|
|
45
|
+
projectWikiTotalRefs: typeof parsed.projectWikiTotalRefs === 'number' ? parsed.projectWikiTotalRefs : 0,
|
|
46
|
+
byId: parsed.byId && typeof parsed.byId === 'object' ? parsed.byId : {},
|
|
47
|
+
byFile: parsed.byFile && typeof parsed.byFile === 'object' ? parsed.byFile : {},
|
|
48
|
+
byProjectWikiFile: parsed.byProjectWikiFile && typeof parsed.byProjectWikiFile === 'object'
|
|
49
|
+
? parsed.byProjectWikiFile
|
|
50
|
+
: {},
|
|
51
|
+
lastUpdatedAt: typeof parsed.lastUpdatedAt === 'string' ? parsed.lastUpdatedAt : '',
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return emptyUsage();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* 判定 filePath 是否命中 knowledge/{三类}/*.md。
|
|
60
|
+
* 命中返回相对 knowledgeDir 的 posix 风格路径(如 best-practices/xxx.md),否则返回 null。
|
|
61
|
+
*/
|
|
62
|
+
function matchKnowledgeFile(knowledgeDir, filePath) {
|
|
63
|
+
const absKnowledge = path.resolve(knowledgeDir);
|
|
64
|
+
const absFile = path.resolve(filePath);
|
|
65
|
+
const rel = path.relative(absKnowledge, absFile);
|
|
66
|
+
// 越出 knowledge/ 目录
|
|
67
|
+
if (rel.startsWith('..') || path.isAbsolute(rel))
|
|
68
|
+
return null;
|
|
69
|
+
const segments = rel.split(path.sep);
|
|
70
|
+
// 必须形如 <category>/<file>.md(恰好两段,category 合法,文件非 . 开头且 .md 结尾)
|
|
71
|
+
if (segments.length !== 2)
|
|
72
|
+
return null;
|
|
73
|
+
const [category, file] = segments;
|
|
74
|
+
if (!KNOWLEDGE_CATEGORIES.includes(category))
|
|
75
|
+
return null;
|
|
76
|
+
if (file.startsWith('.') || !file.endsWith('.md'))
|
|
77
|
+
return null;
|
|
78
|
+
return `${category}/${file}`;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* 判定 filePath 是否命中当前工作区内的 ProjectWiki Markdown。
|
|
82
|
+
* 命中返回相对 repoRoot 的 posix 风格路径,否则返回 null。
|
|
83
|
+
*/
|
|
84
|
+
function matchProjectWikiFile(repoRoot, filePath) {
|
|
85
|
+
const absRoot = path.resolve(repoRoot);
|
|
86
|
+
const absFile = path.resolve(filePath);
|
|
87
|
+
const rel = path.relative(absRoot, absFile);
|
|
88
|
+
if (rel.startsWith('..') || path.isAbsolute(rel))
|
|
89
|
+
return null;
|
|
90
|
+
const segments = rel.split(path.sep);
|
|
91
|
+
if (!segments.includes('.project-wiki'))
|
|
92
|
+
return null;
|
|
93
|
+
const file = segments[segments.length - 1] ?? '';
|
|
94
|
+
if (file.startsWith('.') || !file.endsWith('.md'))
|
|
95
|
+
return null;
|
|
96
|
+
return segments.join('/');
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* 清晰可复用的上报接口:累加一次引用。
|
|
100
|
+
* - filePath 未命中 knowledge/ 三类目录 → no-op 返回 false。
|
|
101
|
+
* - 命中则读 frontmatter 取 id(缺 id 仅按 byFile 计数),同步累加 byId / byFile / totalRefs。
|
|
102
|
+
* - 原子写回;写失败静默吞掉(旁路指标不得阻断主流程)。
|
|
103
|
+
*
|
|
104
|
+
* @returns 是否实际累加
|
|
105
|
+
*/
|
|
106
|
+
export async function reportKnowledgeRef(knowledgeDir, event) {
|
|
107
|
+
const relPath = matchKnowledgeFile(knowledgeDir, event.filePath);
|
|
108
|
+
const repoRoot = path.dirname(path.dirname(path.resolve(knowledgeDir)));
|
|
109
|
+
const projectWikiRelPath = matchProjectWikiFile(repoRoot, event.filePath);
|
|
110
|
+
if (!relPath && !projectWikiRelPath)
|
|
111
|
+
return false;
|
|
112
|
+
const refAt = event.refAt || getBeijingTime();
|
|
113
|
+
// 读 id(失败不影响 byFile 计数)
|
|
114
|
+
let id = '';
|
|
115
|
+
if (relPath)
|
|
116
|
+
try {
|
|
117
|
+
const abs = path.join(knowledgeDir, relPath);
|
|
118
|
+
if (await FileSystemUtils.fileExists(abs)) {
|
|
119
|
+
id = parseFrontMatter(await FileSystemUtils.readFile(abs)).id;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
// 忽略读失败,id 留空
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
const usage = await readUsageStats(knowledgeDir);
|
|
127
|
+
const bump = (counter) => ({
|
|
128
|
+
refCount: (counter?.refCount ?? 0) + 1,
|
|
129
|
+
lastRefAt: refAt,
|
|
130
|
+
});
|
|
131
|
+
if (relPath) {
|
|
132
|
+
usage.byFile[relPath] = bump(usage.byFile[relPath]);
|
|
133
|
+
if (id)
|
|
134
|
+
usage.byId[id] = bump(usage.byId[id]);
|
|
135
|
+
usage.totalRefs += 1;
|
|
136
|
+
}
|
|
137
|
+
if (projectWikiRelPath) {
|
|
138
|
+
usage.byProjectWikiFile[projectWikiRelPath] = bump(usage.byProjectWikiFile[projectWikiRelPath]);
|
|
139
|
+
usage.projectWikiTotalRefs += 1;
|
|
140
|
+
}
|
|
141
|
+
usage.lastUpdatedAt = refAt;
|
|
142
|
+
await FileSystemUtils.createDirectory(path.dirname(getUsagePath(knowledgeDir)));
|
|
143
|
+
await atomicWriteJson(getUsagePath(knowledgeDir), usage);
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
// 写失败静默:上报是旁路指标,绝不能影响 Read 工具结果
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
async function readStdin() {
|
|
152
|
+
return new Promise((resolve) => {
|
|
153
|
+
let data = '';
|
|
154
|
+
process.stdin.setEncoding('utf8');
|
|
155
|
+
process.stdin.on('data', (chunk) => (data += chunk));
|
|
156
|
+
process.stdin.on('end', () => resolve(data));
|
|
157
|
+
process.stdin.on('error', () => resolve(null));
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* PostToolUse hook 入口:从 stdin(tool_input.file_path) 或 --file 提取路径后上报。
|
|
162
|
+
* 始终输出 { continue: true } 信封并以 0 退出(旁路 hook)。
|
|
163
|
+
*/
|
|
164
|
+
export async function knowledgeUsageHook(options) {
|
|
165
|
+
let stdinData = {};
|
|
166
|
+
try {
|
|
167
|
+
const stdinContent = await readStdin();
|
|
168
|
+
if (stdinContent)
|
|
169
|
+
stdinData = JSON.parse(stdinContent);
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
// 忽略解析错误
|
|
173
|
+
}
|
|
174
|
+
const toolInput = stdinData.tool_input;
|
|
175
|
+
const filePath = toolInput?.file_path || toolInput?.path || options.file || '';
|
|
176
|
+
if (filePath) {
|
|
177
|
+
try {
|
|
178
|
+
const repoRoot = resolveZhuanSpecRoot(process.cwd());
|
|
179
|
+
const knowledgeDir = path.join(repoRoot, 'zhuanspec', 'knowledge');
|
|
180
|
+
await reportKnowledgeRef(knowledgeDir, { filePath });
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
// 旁路 hook 永不抛错
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const envelope = { continue: true, hookSpecificOutput: { hookEventName: 'PostToolUse' } };
|
|
187
|
+
const payload = detectHookHost() === 'codex'
|
|
188
|
+
? sanitizeCodexEnvelope(envelope, 'PostToolUse')
|
|
189
|
+
: envelope;
|
|
190
|
+
console.log(JSON.stringify(payload));
|
|
191
|
+
}
|
|
192
|
+
//# sourceMappingURL=knowledge-usage.js.map
|