@xulthekl/team-flow 0.56.1 → 0.57.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.
Files changed (53) hide show
  1. package/.claude/always/phase-guard.md +1 -1
  2. package/.claude-plugin/marketplace.json +1 -1
  3. package/.claude-plugin/plugin.json +1 -1
  4. package/.codex-plugin/plugin.json +1 -1
  5. package/.cursor-plugin/marketplace.json +1 -1
  6. package/.cursor-plugin/plugin.json +1 -1
  7. package/.github/plugin/marketplace.json +2 -2
  8. package/AGENTS.md +17 -11
  9. package/CHANGELOG.md +85 -0
  10. package/GEMINI.md +1 -1
  11. package/INSTALL.md +1 -1
  12. package/README.md +1 -1
  13. package/docs/README_en.md +1 -1
  14. package/docs/solutions/INDEX.md +3 -3
  15. package/docs/usage-guide.md +1 -1
  16. package/gemini-extension.json +1 -1
  17. package/hooks/session-start +2 -2
  18. package/llms.txt +1 -1
  19. package/package.json +1 -1
  20. package/plugin.json +1 -1
  21. package/scripts/lib/cmd-doctor.mjs +140 -1
  22. package/scripts/lib/cmd-solutions.mjs +14 -4
  23. package/scripts/lib/md-normalize.mjs +39 -0
  24. package/scripts/lib/solutions-backfill.mjs +116 -0
  25. package/scripts/lib/solutions-capture.mjs +67 -20
  26. package/scripts/lib/solutions-entry.mjs +185 -0
  27. package/scripts/lib/solutions-index-gen.mjs +117 -57
  28. package/scripts/lib/solutions-inject.mjs +102 -24
  29. package/scripts/lib/solutions-promote.mjs +147 -95
  30. package/scripts/lib/test-merge.mjs +73 -12
  31. package/scripts/team-flow.mjs +7 -3
  32. package/skills/architecture-design/SKILL.md +2 -2
  33. package/skills/architecture-design/references/s3.5-product-architecture.md +1 -1
  34. package/skills/build-executor/SKILL.md +5 -1
  35. package/skills/ce-brainstorm/references/grounding.md +2 -2
  36. package/skills/ce-compound/references/promotion-rules.md +26 -9
  37. package/skills/ce-compound/references/schema.yaml +4 -2
  38. package/skills/ce-compound/references/three-tier-index.md +10 -7
  39. package/skills/ce-compound/references/write-flow.md +22 -10
  40. package/skills/ce-ideate/references/agents/learnings-researcher.md +9 -2
  41. package/skills/ce-ideate/references/grounding.md +1 -1
  42. package/skills/ce-plan/references/agents/learnings-researcher.md +9 -2
  43. package/skills/ce-plan/references/research-workflow.md +2 -2
  44. package/skills/code-reviewer/SKILL.md +7 -0
  45. package/skills/code-reviewer/code-reviewer-prompt.md +6 -0
  46. package/skills/contract-builder/SKILL.md +9 -0
  47. package/skills/release-archivist/SKILL.md +2 -2
  48. package/skills/release-archivist/references/closing-procedures.md +3 -1
  49. package/skills/spec-writer/SKILL.md +1 -1
  50. package/skills/workflow-orchestrator/SKILL.md +2 -2
  51. package/skills/workflow-orchestrator/references/s1-path-router.md +4 -2
  52. package/skills/workflow-orchestrator/references/s3-plan-pipeline.md +1 -1
  53. package/templates/learnings.md +17 -5
@@ -1,69 +1,105 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * solutions-index-gen — 扫描 docs/solutions/<phase>/ 各阶段目录,重建 INDEX.md
3
+ * solutions-index-gen — 扫描 docs/solutions/<phase>/ 重建 INDEX.md
4
4
  *
5
- * v0.5 复利贯穿机制脚本
5
+ * v0.5 复利贯穿机制脚本;v0.57.0 §4.2 重构为共享入口
6
6
  * 用法:tf solutions index-gen [--dir <solutions-dir>]
7
7
  *
8
8
  * 功能:
9
- * 1. 扫描 docs/solutions/ 下所有子目录中的 .md 文件
10
- * 2. 解析每个文件的 frontmatter(phase/domain/type/severity/date/summary)
11
- * 3. 按 severity 降序、date 降序排列
12
- * 4. 重建 INDEX.md(≤150 行硬上限)
13
- * 5. 超出 150 条时,淘汰 low severity 且 date 最早的条目
9
+ * 1. 扫描 docs/solutions/<phase>/ 下的 .md 文件(phase 枚举见 solutions-phases.mjs)
10
+ * 2. 解析 frontmatter(phase/domain/type/severity/date,以及可选的 title)
11
+ * 3. 按 severity 降序 → date 降序 → file 兜底排列(末键保证同日同 severity 的去留可复现)
12
+ * 4. 重建 INDEX.md(≤ MAX_INDEX_ENTRIES 条)
13
+ * 5. 超出上限时按序保留前 N 条,其余**保留在磁盘**并计入 dropped
14
+ *
15
+ * v0.57.0 §4.2 变更(设计文档 compound-lifecycle-governance-design v2.2):
16
+ * - **抽 `refreshIndex()` 为共享入口**:capture / promote 在写完条目文件后**各自内部调用**,
17
+ * 取代 v2.1 的"在调用点逐一追加"——promote 有 5 处调用点、capture 有 4 处,逐处追加必然漏改。
18
+ * - **目录缺失时创建**(原直接 `exit 1`):改由 capture/promote 调用后,首次使用时
19
+ * `docs/solutions/` 可能尚不存在,失败会让整条晋升链中断。
20
+ * - **severity 单向取自条目文件**:INDEX 是**投影**。设计曾考虑"重建时取 max(旧 INDEX, 文件)"
21
+ * 以防升级丢失——已**否决**:那会让 INDEX 成为并列真值源,把漂移固化为不可降档的不变量。
22
+ * - summary 优先取 frontmatter 的 `title:`(v0.57.0 起 CLI 条目会写),无则回退正文首行。
23
+ * - 截断由**静默**改为 **WARN + dropped 明细**(截断从"从未触发"变为"每次晋升必经")。
24
+ *
25
+ * v0.57.0 P4 修复(评审轮 M2/M3/m4):
26
+ * - **单元格转义**:内容含 `|` 会让该行列数膨胀 ⇒ 消费方列数校验丢弃整行 ⇒ 条目隐身,
27
+ * 且 doctor 的修复建议(重跑 index-gen)产出同一坏行,形成死循环。见 `escapeCell`。
28
+ * - **加 `source` 列**(7→8):同源聚类的聚类键。inject 约定"只读 INDEX 不读条目文件",
29
+ * 故 source 必须进索引才可用。
30
+ * - **排序补 file 兜底键**:同日同 severity 的去留在 maxEntries 边界上原本不可复现。
14
31
  */
15
32
 
16
- import { readFileSync, writeFileSync, readdirSync, statSync, existsSync } from 'node:fs';
33
+ import { readFileSync, writeFileSync, readdirSync, statSync, existsSync, mkdirSync } from 'node:fs';
17
34
  import { join } from 'node:path';
18
35
  import { pathToFileURL } from 'node:url';
19
36
  import { severityRank } from './severity.mjs';
20
37
  import { SOLUTION_PHASES } from './solutions-phases.mjs';
38
+ // frontmatter 读取的**共享实现**(含行尾注释剥离)——已知例外与理由见 solutions-entry 的 docstring
39
+ import { parseFrontmatter, listSolutionEntries } from './solutions-entry.mjs';
21
40
 
22
- const MAX_INDEX_LINES = 150;
23
-
24
- function parseFrontmatter(content) {
25
- const match = content.match(/^---\n([\s\S]*?)\n---/);
26
- if (!match) return null;
27
- const fm = {};
28
- for (const line of match[1].split('\n')) {
29
- const idx = line.indexOf(':');
30
- if (idx > 0) {
31
- const key = line.slice(0, idx).trim();
32
- const val = line.slice(idx + 1).trim();
33
- fm[key] = val;
34
- }
35
- }
36
- return fm;
41
+ /** INDEX 保留的最大**条目数**(非行数——文件还含 4 行头部) */
42
+ export const MAX_INDEX_ENTRIES = 150;
43
+
44
+ /**
45
+ * 解析条目文件的 frontmatter(扁平键值)——**收敛到共享层**(v0.57.0 P4 二轮)。
46
+ *
47
+ * 原实现在此**裸读**、不剥行尾注释,后果不是"显示难看"而是静默错判:
48
+ * 注释值原样进 INDEX ⇒ `severityRank` 落未知值档 ⇒ 高危条目排到 `low` 之后、
49
+ * 在 `--limit` 窗口下最先被截掉。
50
+ *
51
+ * 注意必须**先 import 再 export**:`export { X } from '...'` 只做转发,
52
+ * **不会**在本模块作用域建立绑定,而 `refreshIndex` 内部正要调用它。
53
+ * 保留本名导出以兼容既有消费者(`cmd-doctor` 等)。
54
+ */
55
+ export { parseFrontmatter };
56
+
57
+ /**
58
+ * 转义 INDEX 表格单元格(v0.57.0 P4 修复)。
59
+ *
60
+ * 不转义的后果**比"脏"严重**:summary 含一个 `|` ⇒ 该行列数变 8 ⇒ 消费方
61
+ * (`solutions-inject` / `cmd-doctor`)的列数校验直接丢弃整行 ⇒ 条目**永久隐身**。
62
+ * 更糟的是 `tf doctor` 报出漂移后建议"运行 index-gen 重建",而重建会产出同一坏行
63
+ * ⇒ **修复建议永不生效的死循环**(v0.38.0 test-merge 同型缺陷的另一面)。
64
+ *
65
+ * 换行也压平:表格行内含 `\n` 会把一行表格炸成两行。
66
+ */
67
+ function escapeCell(value) {
68
+ return String(value ?? '')
69
+ .replace(/\|/g, '\\|')
70
+ .replace(/\s+/g, ' ')
71
+ .trim();
37
72
  }
38
73
 
39
- function extractSummary(content) {
40
- // 取 frontmatter 后第一个非空行作为摘要
74
+ function extractSummary(content, fm) {
75
+ // v0.57.0:优先取 frontmatter 的 title(CLI 条目自本版起写该字段);
76
+ // 回退"frontmatter 后第一个非空行"——但正文首行常是 `- **教训来源**:…` 型元数据,
77
+ // 故回退路径的摘要质量低于 title。
78
+ if (fm?.title) return String(fm.title).slice(0, 80);
41
79
  const body = content.replace(/^---\n[\s\S]*?\n---\n*/, '');
42
80
  const lines = body.split('\n').filter(l => l.trim() && !l.startsWith('#'));
43
81
  return lines[0]?.trim().slice(0, 80) || '(no summary)';
44
82
  }
45
83
 
46
- export function run(args = {}) {
47
- const dir = args.dir || 'docs/solutions';
48
- const indexPath = join(dir, 'INDEX.md');
49
-
50
- if (!existsSync(dir)) {
51
- console.error(`Solutions directory not found: ${dir}`);
52
- process.exit(1);
53
- }
84
+ /**
85
+ * 重建 `docs/solutions/INDEX.md` —— capture / promote / CLI 共用的**唯一** INDEX 写入入口。
86
+ *
87
+ * @param {string} dir solutions 根目录
88
+ * @param {{phases?: readonly string[], maxEntries?: number, quiet?: boolean}} [options]
89
+ * @returns {{kept: number, dropped: number, droppedEntries: Array<{file: string, severity: string, date: string}>}}
90
+ */
91
+ export function refreshIndex(dir, { phases = SOLUTION_PHASES, maxEntries = MAX_INDEX_ENTRIES, quiet = false } = {}) {
92
+ // 目录缺失时创建(原实现 exit 1 —— 会让首次晋升链中断)
93
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
54
94
 
55
95
  const entries = [];
56
96
 
57
- for (const phase of SOLUTION_PHASES) {
58
- const phaseDir = join(dir, phase);
59
- if (!existsSync(phaseDir) || !statSync(phaseDir).isDirectory()) continue;
60
-
61
- for (const file of readdirSync(phaseDir)) {
62
- if (!file.endsWith('.md')) continue;
63
- const filePath = join(phaseDir, file);
64
- const content = readFileSync(filePath, 'utf-8');
97
+ for (const phase of phases) {
98
+ // 条目枚举走**共享层**(与 `cmd-doctor` / `backfill` 同源):它同时承担
99
+ // ①条目判据(判据不一致会让 doctor 报出重跑 index-gen 也消不掉的告警)
100
+ // ②防御(非普通文件 / 读失败不再让整批枚举抛异常)
101
+ for (const { file, content } of listSolutionEntries(join(dir, phase))) {
65
102
  const fm = parseFrontmatter(content);
66
- if (!fm) continue;
67
103
 
68
104
  entries.push({
69
105
  date: fm.date || '1970-01-01',
@@ -71,37 +107,61 @@ export function run(args = {}) {
71
107
  domain: fm.domain || 'general',
72
108
  type: fm.type || 'insight',
73
109
  severity: fm.severity || 'low',
74
- summary: extractSummary(content),
110
+ summary: extractSummary(content, fm),
75
111
  file: `${phase}/${file}`,
112
+ // v0.57.0 P4:source 进 INDEX 供 inject 做同源聚类(此前只存在于条目文件,消费方读不到)
113
+ source: fm.source || '',
76
114
  });
77
115
  }
78
116
  }
79
117
 
80
- // 排序:severity 降序,date 降序
118
+ // 排序:severity 降序,date 降序,**文件名兜底**
119
+ // v0.57.0 P4:前两键相同时(同日同 severity 极常见)去留取决于 readdirSync 顺序,
120
+ // 在 maxEntries 边界上跨机器/跨文件系统不可复现 ⇒ 补 file 作为决定性末键。
81
121
  entries.sort((a, b) => {
82
122
  const sevDiff = severityRank(a.severity) - severityRank(b.severity);
83
123
  if (sevDiff !== 0) return sevDiff;
84
- return b.date.localeCompare(a.date);
124
+ const dateDiff = b.date.localeCompare(a.date);
125
+ if (dateDiff !== 0) return dateDiff;
126
+ return a.file.localeCompare(b.file);
85
127
  });
86
128
 
87
- // 截断到 150 条
88
- const kept = entries.slice(0, MAX_INDEX_LINES);
89
- const dropped = entries.slice(MAX_INDEX_LINES);
129
+ const kept = entries.slice(0, maxEntries);
130
+ const droppedEntries = entries.slice(maxEntries);
90
131
 
91
- // 生成 INDEX.md
132
+ // v0.57.0 P4:加 `source` 列(7→8)——同源聚类需要它,而 inject 的设计约定是
133
+ // "只读 INDEX、不读条目文件",故聚类键必须进索引。消费方对 7/8 列都容错(见 solutions-inject)。
92
134
  let index = '# Solutions Index\n';
93
- index += '<!-- 每条一行,按 severity 降序,≤150 行硬上限 -->\n';
94
- index += '| date | phase | domain | type | severity | summary | file |\n';
95
- index += '|------|-------|--------|------|----------|---------|------|\n';
135
+ index += `<!-- 每条一行,排序 severity 降序 → date 降序 → file 兜底,≤${maxEntries} 条上限;summary/source 内的 | 转义为 \\| -->\n`;
136
+ index += '| date | phase | domain | type | severity | summary | file | source |\n';
137
+ index += '|------|-------|--------|------|----------|---------|------|--------|\n';
96
138
  for (const e of kept) {
97
- index += `| ${e.date} | ${e.phase} | ${e.domain} | ${e.type} | ${e.severity} | ${e.summary} | ${e.file} |\n`;
139
+ index += `| ${escapeCell(e.date)} | ${escapeCell(e.phase)} | ${escapeCell(e.domain)} | ${escapeCell(e.type)} | ${escapeCell(e.severity)} | ${escapeCell(e.summary)} | ${escapeCell(e.file)} | ${escapeCell(e.source)} |\n`;
98
140
  }
99
141
 
100
- writeFileSync(indexPath, index, 'utf-8');
101
- console.log(`INDEX.md rebuilt: ${kept.length} entries (${dropped.length} dropped)`);
102
- if (dropped.length > 0) {
103
- console.log(`Dropped ${dropped.length} low-priority entries (files preserved in directories)`);
142
+ writeFileSync(join(dir, 'INDEX.md'), index, 'utf-8');
143
+
144
+ if (!quiet) {
145
+ console.log(`INDEX.md rebuilt: ${kept.length} entries (${droppedEntries.length} dropped)`);
104
146
  }
147
+ if (droppedEntries.length > 0) {
148
+ console.warn(`⚠ ${droppedEntries.length} 条经验超出 INDEX 上限(${maxEntries} 条),未被索引(文件保留在磁盘):`);
149
+ for (const e of droppedEntries) {
150
+ console.warn(` - [${e.severity}] ${e.date} ${e.file}`);
151
+ }
152
+ console.warn(' 处置建议:提升关键条目 severity,或归档低价值条目(见设计文档 §3.3 退役语义)');
153
+ }
154
+
155
+ return {
156
+ kept: kept.length,
157
+ dropped: droppedEntries.length,
158
+ droppedEntries: droppedEntries.map(e => ({ file: e.file, severity: e.severity, date: e.date })),
159
+ };
160
+ }
161
+
162
+ export function run(args = {}) {
163
+ const dir = args.dir || 'docs/solutions';
164
+ refreshIndex(dir);
105
165
  }
106
166
 
107
167
  // CLI 直接执行
@@ -1,75 +1,153 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * solutions-inject — 按 phase+domain 过滤 INDEX.md,输出 top-5 摘要
3
+ * solutions-inject — 按 phase+domain 过滤 INDEX.md,输出经验摘要供 skill 注入上下文
4
4
  *
5
5
  * v0.5 复利贯穿机制脚本
6
- * 用法:tf solutions inject --phase <p> --domain <d>
6
+ * 用法:tf solutions inject --phase <p> [--domain <d>] [--limit <n> | --all]
7
7
  *
8
8
  * 功能:
9
- * 1. 读取 docs/solutions/INDEX.md
10
- * 2. 过滤:phase = 指定阶段 OR phase = cross-phase,且 domain 匹配
11
- * 3. 按 severity 降序取 top-5
12
- * 4. 输出摘要(供各 skill 注入上下文)
9
+ * 1. 读取 docs/solutions/INDEX.md(**只读 INDEX,不读条目文件**)
10
+ * 2. 过滤:phase = 指定阶段 OR phase = cross-phase(**cross-phase 是通配符**),且 domain 匹配
11
+ * 3. 排序:severity 降序 → 阶段匹配度(本阶段优先于通配)→ date 降序
12
+ * 4. 输出前 N 条(默认 5)摘要
13
+ *
14
+ * v0.57.0 §4.1 变更(设计文档 compound-lifecycle-governance-design v2.2):
15
+ * - **`--phase` 语义**:`ePhase === phase || ePhase === 'cross-phase'`——cross-phase 是**通配符**,
16
+ * 不是"仅本阶段"。故 `--phase prd` 在无 prd 条目的项目上注入的**就是 cross-phase 那批**
17
+ * (实测与 `--phase cross-phase` 输出只差表头一行)。本版**保持该语义**(改语义会破坏既有调用点),
18
+ * 改为用**阶段匹配度次级键**让真正属于本阶段的条目优先。
19
+ * - **排序补次级键**:原实现仅 `severityRank` 单键,同级顺序取决于 INDEX 行序;
20
+ * 新增"本阶段优先"与"date 降序"两级,使新增条目不再被旧条目永久压制。
21
+ * - **`--limit` / `--all`**:原硬编码 top-5(emp-auth 的 cross-phase 恰好 5 条 ⇒ **已满载**,
22
+ * 下一条除非 severity 更高否则永不可达)。调用方 MUST 显式传值——只加参数而不改调用点等于没改。
23
+ * - **列解析**:改用共享层 `parseTableRow`(原 `filter(Boolean)` 丢空列且仅下界守卫)。
24
+ * - **索引缺失由静默改为 WARN**:原输出一行 HTML 注释并以 0 退出,无人可见。
25
+ *
26
+ * v0.57.0 P4 修复(评审轮 M2/M3):
27
+ * - **同源聚类补实现**(上条提到的病因在此收口):`source` 相同的条目折叠为 1 条 + 标注
28
+ * 同源条数。仅作用于展示,**不合并条目内容**(与 D1-B 一致,见下方聚类段注释)。
29
+ * - **列数改为区间校验 + `file` 列语义校验**:`source` 列新增后 7/8 列都合法;
30
+ * 另加 `ENTRY_FILE_RE` 兜住"列数在界内但已错位"(summary 含未转义 `|` 的存量坏行)。
13
31
  */
14
32
 
15
33
  import { readFileSync, existsSync } from 'node:fs';
16
34
  import { join } from 'node:path';
17
35
  import { pathToFileURL } from 'node:url';
18
36
  import { severityRank } from './severity.mjs';
37
+ import { parseTableRow } from './md-normalize.mjs';
38
+
39
+ /**
40
+ * INDEX 表列数范围(date | phase | domain | type | severity | summary | file [| source])。
41
+ * v0.57.0 P4:`source` 列是本版新增 ⇒ 7(存量 INDEX)与 8(本版重建)都要接受。
42
+ */
43
+ const INDEX_COLUMNS_MIN = 7;
44
+ const INDEX_COLUMNS_MAX = 8;
45
+ /** `file` 列的形状(`<phase>/<name>.md`)——语义校验,见下方列解析注释 */
46
+ const ENTRY_FILE_RE = /^[a-z][a-z-]*\/.+\.md$/;
47
+ /** 默认展示条数 */
48
+ const DEFAULT_LIMIT = 5;
19
49
 
20
50
  export function run(args = {}) {
21
51
  const phase = args.phase || 'cross-phase';
22
52
  const domain = args.domain || '';
23
53
  const dir = args.dir || 'docs/solutions';
54
+ const all = args.all === true || args.all === 'true';
55
+ const rawLimit = Number(args.limit);
56
+ const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? rawLimit : DEFAULT_LIMIT;
24
57
  const indexPath = join(dir, 'INDEX.md');
25
58
 
26
59
  if (!existsSync(indexPath)) {
27
- console.log('<!-- No solutions index found, skipping injection -->');
60
+ console.warn(`⚠ 未找到 ${indexPath},跳过复利注入(已捕获的经验不会出现在上下文中)`);
28
61
  return { entries: [] };
29
62
  }
30
63
 
31
64
  const content = readFileSync(indexPath, 'utf-8');
32
- const lines = content.split('\n').filter(l => l.startsWith('|') && !l.startsWith('| date') && !l.startsWith('|--'));
33
-
34
65
  const entries = [];
35
- for (const line of lines) {
36
- const cols = line.split('|').map(c => c.trim()).filter(Boolean);
37
- if (cols.length < 7) continue;
38
- const [date, ePhase, eDomain, type, severity, summary, file] = cols;
39
66
 
40
- // 过滤:phase 匹配或 cross-phase
67
+ for (const line of content.split('\n')) {
68
+ if (!line.startsWith('|') || line.startsWith('| date') || line.startsWith('|--')) continue;
69
+
70
+ const cells = parseTableRow(line);
71
+ // 上下界同时校验:只查下界无法发现"summary 含 `|` 导致列数变多、file 列错位"(v0.38.0 test-merge 同型缺陷)
72
+ if (cells.length < INDEX_COLUMNS_MIN || cells.length > INDEX_COLUMNS_MAX) continue;
73
+ const [date, ePhase, eDomain, type, severity, summary, file, source = ''] = cells;
74
+ // file 列语义校验(v0.57.0 P4):列数在界内仍可能错位——例如 summary 含**未转义**的 `|`
75
+ // 时列数恰好落进 7/8 区间,摘要后半段会顶到 file 位。形状不符即丢弃,避免注入错位的路径。
76
+ if (!ENTRY_FILE_RE.test(file)) continue;
77
+
41
78
  const phaseMatch = ePhase === phase || ePhase === 'cross-phase';
42
- // 过滤:domain 匹配(空 domain 表示通用)
43
79
  const domainMatch = !domain || !eDomain || eDomain === 'general' || eDomain === domain;
44
80
 
45
81
  if (phaseMatch && domainMatch) {
46
- entries.push({ date, phase: ePhase, domain: eDomain, type, severity, summary, file });
82
+ entries.push({ date, phase: ePhase, domain: eDomain, type, severity, summary, file, source, isNative: ePhase === phase });
47
83
  }
48
84
  }
49
85
 
50
- // 按 severity 降序排序,取 top-5
51
- entries.sort((a, b) => severityRank(a.severity) - severityRank(b.severity));
52
- const top5 = entries.slice(0, 5);
86
+ entries.sort((a, b) => {
87
+ const sevDiff = severityRank(a.severity) - severityRank(b.severity);
88
+ if (sevDiff !== 0) return sevDiff;
89
+ // 阶段匹配度:本阶段条目优先于通配的 cross-phase
90
+ if (a.isNative !== b.isNative) return a.isNative ? -1 : 1;
91
+ // 时间:新条目优先
92
+ return b.date.localeCompare(a.date);
93
+ });
94
+
95
+ // ── 同源聚类(v0.57.0 P4 补实现,设计 §4.1)──────────────────────────────
96
+ // D1-B 废止条目合并后,同一 change 产生的多条经验会各自成条——这是对的(内容零丢失),
97
+ // 但它们的 severity 与 date 通常相同 ⇒ 排序后**连续占满** top-N,把窗口的信息增益
98
+ // 压缩成"一个 change 的 N 条"。故折叠为 1 条并标注同源条数,把槽位还给其他经验。
99
+ // 为什么不合并条目本身:那正是 D1-B 废止的行为(实测丢 13 条内容)。折叠只作用于**展示**。
100
+ // `source` 为空(存量条目或手工条目)不参与聚类——没有可靠聚类键就不猜。
101
+ //
102
+ // ⚠ 已知假设:聚类键 `source` 是 **change 名**,故本实现假设"同一 change 的经验大多相关"
103
+ // (设计 §4.1 的动机是 v1-C3 的 5 个同源工具缺陷)。该假设对产出经验较杂的 change 不成立
104
+ // ——此时折叠会藏掉不相关信息。这是**有意的取舍**(窗口信息增益 vs 单条可达性),
105
+ // 且折叠只是展示层:条目仍在盘上、仍可被直接读取。若日后要收紧,正确的方向是引入
106
+ // 更细的聚类键(如事故 id),而不是关掉折叠。
107
+ const bySource = new Map();
108
+ const clustered = [];
109
+ for (const e of entries) {
110
+ if (!e.source) { clustered.push(e); continue; }
111
+ const head = bySource.get(e.source);
112
+ if (head) { head.siblings++; continue; }
113
+ const node = { ...e, siblings: 0 };
114
+ bySource.set(e.source, node);
115
+ clustered.push(node);
116
+ }
117
+ const foldedCount = entries.length - clustered.length;
118
+
119
+ const shown = all ? clustered : clustered.slice(0, limit);
53
120
 
54
- if (top5.length === 0) {
55
- console.log('<!-- No matching solutions found -->');
121
+ if (shown.length === 0) {
122
+ console.log(`<!-- No matching solutions found (phase=${phase}, domain=${domain || 'any'}) -->`);
56
123
  return { entries: [] };
57
124
  }
58
125
 
59
- console.log(`## Solutions Context (phase=${phase}, domain=${domain || 'any'})`);
126
+ const foldedNote = foldedCount > 0 ? `, ${foldedCount} 条同源已折叠` : '';
127
+ console.log(`## Solutions Context (phase=${phase}, domain=${domain || 'any'}, showing ${shown.length}/${clustered.length}${foldedNote})`);
60
128
  console.log('');
61
- for (const e of top5) {
129
+ for (const e of shown) {
62
130
  console.log(`- [${e.severity}] ${e.summary} (${e.phase}/${e.domain}, ${e.date}) → ${e.file}`);
131
+ if (e.siblings > 0) {
132
+ console.log(` ↳ 另有 ${e.siblings} 条同源经验(source=${e.source})已折叠;需要时按 file 直接读取`);
133
+ }
63
134
  }
64
135
  console.log('');
65
136
 
66
- return { entries: top5 };
137
+ const hidden = clustered.length - shown.length;
138
+ if (hidden > 0) {
139
+ console.log(`<!-- 另有 ${hidden} 条匹配经验未展开:调大 --limit 或使用 --all -->`);
140
+ console.log('');
141
+ }
142
+
143
+ return { entries: shown, total: clustered.length, folded: foldedCount, hidden };
67
144
  }
68
145
 
69
146
  if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
70
147
  const args = {};
71
148
  for (let i = 2; i < process.argv.length; i += 2) {
72
149
  const key = process.argv[i]?.replace('--', '');
150
+ if (key === 'all') { args.all = true; i -= 1; continue; }
73
151
  const val = process.argv[i + 1];
74
152
  if (key && val) args[key] = val;
75
153
  }