@xulthekl/team-flow 0.56.0 → 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 +104 -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 +101 -14
  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
@@ -106,3 +106,42 @@ export function parseTaskLine(line) {
106
106
  if (!m) return null;
107
107
  return { indent: m[1].length, checked: m[3].toLowerCase() === 'x', text: m[4] };
108
108
  }
109
+
110
+ /**
111
+ * 解析 Markdown 表格行 → 单元格字符串数组(**保留空列**)。
112
+ *
113
+ * v0.57.0 §4.4 抽为共享层。背景:`solutions-inject.mjs` 此前用
114
+ * `split('|').map(c => c.trim()).filter(Boolean)` —— `filter(Boolean)` **会丢空列**,
115
+ * 而守卫只有下界(`cols.length < 7`)⇒ 某单元格内容含 `|` 时列数变多、**不被拦截**,
116
+ * `file` 列取到错误值(与 `test-merge.mjs` 在 v0.38.0 修过的同型缺陷一致,当时未横展)。
117
+ *
118
+ * 约定:
119
+ * - `split('|')` 对 `| a | b |` 产出 `['', ' a ', ' b ', '']`,故取 `slice(1, -1)` 去首尾空串
120
+ * - 支持 `\|` 转义(v0.57.0 P4):生成侧 `escapeCell` 把内容里的 `|` 写成 `\|`,
121
+ * 此处先换占位符再切分,否则转义符形同虚设(`\|` 仍会被 `split` 切开)。
122
+ * 无 `\|` 时与裸 `split('|').slice(1,-1)` **逐格一致**(含行尾缺竖线的非良构行——
123
+ * 两者对 `| a | b | c` 都返回 2 格)。与**本模块替换掉的旧版 `filter(Boolean)` 实现**
124
+ * 的差异才是实质的:旧版无 `slice`、且 `filter(Boolean)` 丢空列 ⇒ 同样的
125
+ * `| a | b | c` 返回 3 格、而 `| a | | c |` 返回 2 格(空列消失导致后续列全部左移)
126
+ * - **不做**列数校验——期望列数由调用方决定(INDEX 为 7/8 列,test-matrix 为 12 列)
127
+ * - 调用方 MUST 同时校验**上界与下界**(只查下界无法发现"多出的 `|` 导致的错位")
128
+ *
129
+ * ⚠ 同名函数差异矩阵(v0.57.0 P4 评审补充,避免按名字误挑):
130
+ * | 位置 | 非法输入 | 清洗 |
131
+ * |---|---|---|
132
+ * | 本函数(md-normalize) | **永不返回 null**,非表格行也产出数组 | 仅 trim + 还原 `\|` |
133
+ * | `arch-parse.mjs` | 返回 `null` | strip 反引号与强调标记 |
134
+ * | `ds-parse.mjs` | 返回 `null` | 走 `normalizeInline` |
135
+ * 本函数是最"裸"的一层 —— 调用方须自行判断行是否合法(如 `startsWith('|')`)。
136
+ *
137
+ * @param {string} line
138
+ * @returns {string[]}
139
+ */
140
+ export function parseTableRow(line) {
141
+ const PIPE = '\u0001'; // 控制字符占位:正常 markdown 内容不含
142
+ return String(line)
143
+ .replace(/\\\|/g, PIPE)
144
+ .split('|')
145
+ .slice(1, -1)
146
+ .map(cell => cell.trim().replaceAll(PIPE, '|'));
147
+ }
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * solutions-backfill — 为存量复利条目补写 `title:` 字段
4
+ *
5
+ * v0.57.0 §4.1(设计文档 compound-lifecycle-governance-design v2.2)
6
+ * 用法:tf solutions backfill [--dir <solutions-dir>] [--dry-run]
7
+ *
8
+ * 为什么需要:
9
+ * `ce-plan`/`ce-ideate` 的 `learnings-researcher` 子代理按
10
+ * `title:` / `tags:` / `module:` / `problem_type:` 四个字段 grep 检索
11
+ * (`skills/ce-plan/references/agents/learnings-researcher.md:77-80`),
12
+ * 而 CLI 通道(promote/capture)此前写入的 frontmatter 只有
13
+ * `phase/domain/type/severity/date/source` —— **四个模式一个都命中不了**。
14
+ * 实测:emp-auth 21 个条目文件中,这四个模式**各只命中 1 个**(那条 ce-compound 通道写的条目)。
15
+ *
16
+ * v0.57.0 起 promote/capture 会写 `title:`,但**只改写入模板等于没改**——
17
+ * 存量条目仍检索不到(这正是 P-1 自己批评过的"修 bug 未回填",v0.48.0→v0.51.0 同型)。
18
+ * 本命令补齐存量。
19
+ *
20
+ * title 推导口径(与 `solutions-index-gen` 的 `extractSummary` 同源):
21
+ * ① 正文中的 `## <标题>` 行(promote 写入的形态)
22
+ * ② 回退:首个非空且非 `#` 开头的行
23
+ * 两者均经 `cleanTitle` 去 learnings 的 `N. ` 序号前缀(该序号只在其源文件内有意义)。
24
+ */
25
+
26
+ import { readFileSync, writeFileSync, readdirSync, statSync, existsSync } from 'node:fs';
27
+ import { join } from 'node:path';
28
+ import { pathToFileURL } from 'node:url';
29
+ import { SOLUTION_PHASES } from './solutions-phases.mjs';
30
+ import { refreshIndex } from './solutions-index-gen.mjs';
31
+ import { listSolutionEntries } from './solutions-entry.mjs';
32
+
33
+ /** 与 solutions-promote 的 cleanTitle 同口径:去 `N. ` / `N、` 序号前缀 */
34
+ function cleanTitle(raw) {
35
+ return String(raw ?? '').replace(/^\d+[.、]\s*/, '').trim();
36
+ }
37
+
38
+ /** 从条目正文推导 title */
39
+ export function deriveTitle(content) {
40
+ const body = content.replace(/^---\n[\s\S]*?\n---\n*/, '');
41
+
42
+ const heading = body.match(/^##\s+(.+)$/m);
43
+ if (heading) return cleanTitle(heading[1]);
44
+
45
+ const line = body.split('\n').map(l => l.trim()).find(l => l && !l.startsWith('#'));
46
+ return line ? cleanTitle(line).slice(0, 80) : null;
47
+ }
48
+
49
+ export function run(args = {}) {
50
+ const dir = args.dir || 'docs/solutions';
51
+ const dryRun = args['dry-run'] === true || args['dry-run'] === 'true';
52
+
53
+ if (!existsSync(dir)) {
54
+ console.error(`Solutions directory not found: ${dir}`);
55
+ return { filled: 0, already: 0, skipped: 0 };
56
+ }
57
+
58
+ let filled = 0;
59
+ let already = 0;
60
+ const skipped = [];
61
+
62
+ for (const phase of SOLUTION_PHASES) {
63
+ // 条目枚举走**共享层**(与 index-gen / doctor 同源):含条目判据与"非普通文件不抛异常"防御。
64
+ // 副作用(有意):阶段目录下的**非条目文档**(README / 格式说明)不再出现在 skipped 里——
65
+ // 它们本就不是"待补 title 的条目",列出来只是噪音。
66
+ //
67
+ // ⚠️ 但下方**刻意**不复用共享层的 `parseFrontmatter`:本脚本只做**键存在性检测**
68
+ // 与**整块替换**(需要带 `---` 的完整匹配),不读取任何 frontmatter 值,
69
+ // 故不涉及"行尾注释剥离"那一类静默错判(P4 二轮 N1)。若日后需要读值,请改走共享层。
70
+ for (const { file, filePath, content } of listSolutionEntries(join(dir, phase))) {
71
+ const block = content.match(/^---\n([\s\S]*?)\n---/);
72
+ if (!block) {
73
+ skipped.push(`${phase}/${file}(无 frontmatter)`);
74
+ continue;
75
+ }
76
+ // 容忍全角冒号与加粗(沿用 FB-4 的容忍面)
77
+ if (/^\**title\**\s*[::]/m.test(block[1])) {
78
+ already += 1;
79
+ continue;
80
+ }
81
+
82
+ const title = deriveTitle(content);
83
+ if (!title) {
84
+ skipped.push(`${phase}/${file}(无法推导标题)`);
85
+ continue;
86
+ }
87
+
88
+ if (!dryRun) {
89
+ writeFileSync(filePath, content.replace(block[0], `---\n${block[1]}\ntitle: ${title}\n---`), 'utf-8');
90
+ }
91
+ filled += 1;
92
+ }
93
+ }
94
+
95
+ // INDEX 由共享入口派生(title 会成为新的摘要来源)
96
+ if (!dryRun && filled > 0) refreshIndex(dir, { quiet: true });
97
+
98
+ console.log(
99
+ `Backfill${dryRun ? '(dry-run)' : ''}: ${filled} filled, ${already} already had title, ${skipped.length} skipped`,
100
+ );
101
+ if (skipped.length > 0) {
102
+ console.log('Skipped:');
103
+ for (const item of skipped) console.log(` - ${item}`);
104
+ }
105
+
106
+ return { filled, already, skipped: skipped.length };
107
+ }
108
+
109
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
110
+ const args = {};
111
+ for (let i = 2; i < process.argv.length; i += 1) {
112
+ if (process.argv[i] === '--dry-run') { args['dry-run'] = true; continue; }
113
+ if (process.argv[i] === '--dir' && process.argv[i + 1]) { args.dir = process.argv[++i]; }
114
+ }
115
+ run(args);
116
+ }
@@ -1,25 +1,35 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * solutions-capture — 写入一条经验到 INDEX.md + 对应目录
3
+ * solutions-capture — 写入一条经验到 docs/solutions/<phase>/ + 刷新 INDEX
4
4
  *
5
5
  * v0.5 复利贯穿机制脚本
6
6
  * 用法:tf solutions capture --phase <p> --domain <d> --type <t> --severity <s> --summary "<text>"
7
7
  * --severity 合法取值:critical | high | medium | low(v0.23 §91.3.4 起校验,非法值报错退出)
8
8
  *
9
9
  * 功能:
10
- * 1. 在对应阶段目录下创建经验文件
11
- * 2. 在 INDEX.md 中追加一行摘要
12
- * 3. INDEX.md 超过 150 行时触发 index-gen 重建
10
+ * 1. 在对应阶段目录下创建经验文件(含 `title:` 字段,v0.57.0 起)
11
+ * 2. 调用共享入口 `refreshIndex()` 重建 INDEX.md(本脚本**不再直接写 INDEX**)
12
+ *
13
+ * v0.57.0 §4.2(设计文档 compound-lifecycle-governance-design v2.2):
14
+ * - 原实现以 `appendFileSync` 追加 INDEX 行,且是"INDEX 首次创建"(含表头)的**唯一来源**。
15
+ * 将写入权收归 `refreshIndex` 后,由它统一负责首建与重建——否则 capture 路径写下的条目
16
+ * 在下次 promote 前不在 INDEX 中(注入不可见),且无人再负责首次建索引。
17
+ * - 新增 `title:` 字段:`learnings-researcher` 按 `title:`/`tags:`/`module:`/`problem_type:`
18
+ * 检索,而 CLI 条目此前只有 `phase/domain/type/...` ⇒ 实测 4 个模式在 21 条中**各只命中 1 条**。
19
+ * - 删除本地的容量自查(原按"150 行"打印提示,**口径有误**——上限实为 150 **条**,文件另含 4 行头部)
20
+ * ——容量由 `refreshIndex` 统一处理并 WARN。
13
21
  */
14
22
 
15
- import { readFileSync, writeFileSync, existsSync, mkdirSync, appendFileSync } from 'node:fs';
23
+ import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'node:fs';
16
24
  import { join } from 'node:path';
17
25
  import { pathToFileURL } from 'node:url';
18
- import { SEVERITY_VALUES, isSeverity } from './severity.mjs';
26
+ import { SEVERITY_VALUES, isSeverity, severityRank } from './severity.mjs';
19
27
  import { SOLUTION_PHASES } from './solutions-phases.mjs';
20
28
  import { slugify } from './slug.mjs';
29
+ import { refreshIndex } from './solutions-index-gen.mjs';
30
+ // 条目身份与碰撞定位——与 promote 共用的**共享层**(v0.57.0 P4)
31
+ import { entryBodySignature, resolveEntryPath, readFrontmatterValue, MAX_COLLISION_SUFFIX } from './solutions-entry.mjs';
21
32
 
22
- const MAX_INDEX_LINES = 150;
23
33
  const SLUG_MAX_LENGTH = 40;
24
34
 
25
35
  export function run(args = {}) {
@@ -60,6 +70,8 @@ export function run(args = {}) {
60
70
  const filePath = join(phaseDir, fileName);
61
71
 
62
72
  // 写经验文件
73
+ // `title` 须为单行(它是 INDEX 摘要与 `learnings-researcher` 的检索字段)
74
+ const title = String(summary).replace(/\s*\n\s*/g, ' ').trim();
63
75
  const content = `---
64
76
  phase: ${phase}
65
77
  domain: ${domain}
@@ -67,6 +79,7 @@ type: ${type}
67
79
  severity: ${severity}
68
80
  date: ${date}
69
81
  source: ${source}
82
+ title: ${title}
70
83
  ---
71
84
 
72
85
  ## 问题描述
@@ -78,24 +91,58 @@ ${summary}
78
91
  ## 预防措施/应用方式
79
92
  (待补充)
80
93
  `;
81
- writeFileSync(filePath, content, 'utf-8');
82
94
 
83
- // 追加 INDEX.md
84
- const indexPath = join(dir, 'INDEX.md');
85
- if (!existsSync(indexPath)) {
86
- writeFileSync(indexPath, '# Solutions Index\n<!-- 每条一行,按 severity 降序,≤150 行硬上限 -->\n| date | phase | domain | type | severity | summary | file |\n|------|-------|--------|------|----------|---------|------|\n', 'utf-8');
95
+ // v0.57.0 P4:碰撞保护 —— 原实现无条件 `writeFileSync`,同日同 slug 会**静默覆盖**
96
+ // (丢正文、丢已升的 severity、丢 confirmations),使"内容零丢失"在本通道为假。
97
+ // 身份判据与 promote 共用共享层(`solutions-entry.mjs`),避免两条通道各写一份实现。
98
+ const resolved = resolveEntryPath(phaseDir, date, slug, entryBodySignature(content));
99
+ if (!resolved) {
100
+ console.error(`✗ 同标题条目已达上限 ${MAX_COLLISION_SUFFIX} 条,未写入:${phase}/${slug}-*.md`);
101
+ process.exit(1);
87
102
  }
88
- appendFileSync(indexPath, `| ${date} | ${phase} | ${domain} | ${type} | ${severity} | ${summary.slice(0, 80)} | ${phase}/${fileName} |\n`, 'utf-8');
103
+ if (!resolved.isNew) {
104
+ // 签名相同 = 同一条经验(重复 capture)⇒ 幂等,不重复写入、不覆盖。
105
+ // 但 severity **只升不降**:两次 capture 的差异可能**全在 frontmatter**(正文同、severity 不同),
106
+ // 而签名刻意剔除 frontmatter ⇒ 若在此直接返回,用户"提高等级"的意图会被静默吞掉
107
+ // (v0.57.0 P4 实测:`severity: critical` 重复 capture 后条目仍是 `high`)。
108
+ const existing = readFileSync(resolved.filePath, 'utf-8');
109
+ // 读 severity 必须走共享层(**剥行尾注释**)——裸读会把 `high # 注释` 当未知值,
110
+ // 使下面的"只升不降"判据把这次 `medium` 误判为升级,日志打 Upgraded 实为**降级**
111
+ const current = readFrontmatterValue(existing, 'severity') || '';
112
+ const rel = `${phase}/${resolved.fileName}`;
113
+
114
+ // ⚠ `severityRank` 是**越小越严重**(critical=0 … low=3,见 severity.mjs)。
115
+ // 注入/索引侧用它做升序排序(critical 排最前),故此处的"更严重"判据是 `<` 而非 `>`
116
+ // ——写反会让 critical 被判为降级、medium 被判为升级(v0.57.0 P4 实测踩过)。
117
+ if (!current) {
118
+ // 既有条目**没有 severity 行**(手工条目):无从比较档位。
119
+ // 不能把 null 兜成 `''` 后去比较 —— `severityRank('')` 落未知值档(4),
120
+ // 任何合法 severity 都会被判为"升级",于是**日志与返回值谎报升级而文件未变**
121
+ //(v0.57.0 P4 三轮实测:返回 `upgradedFrom: ""` 且文件无 severity 行)。
122
+ console.log(`Already captured: ${rel}(内容一致,未重复写入;既有条目无 severity 行,未比较档位)`);
123
+ return { file: rel, phase, domain, type, severity, alreadyExisted: true };
124
+ }
89
125
 
90
- // 检查是否超过 150 行
91
- const indexContent = readFileSync(indexPath, 'utf-8');
92
- const dataLines = indexContent.split('\n').filter(l => l.startsWith('|') && !l.startsWith('| date') && !l.startsWith('|--'));
93
- if (dataLines.length > MAX_INDEX_LINES) {
94
- console.log(`INDEX.md exceeded ${MAX_INDEX_LINES} lines, consider running: tf solutions index-gen`);
126
+ if (severityRank(severity) < severityRank(current)) {
127
+ writeFileSync(resolved.filePath, existing.replace(/^severity\s*:\s*.+$/m, `severity: ${severity}`), 'utf-8');
128
+ refreshIndex(dir, { quiet: true });
129
+ console.log(`Upgraded: ${rel} severity ${current} → ${severity}(同一条经验,只升不降)`);
130
+ return { file: rel, phase, domain, type, severity, alreadyExisted: true, upgradedFrom: current };
131
+ }
132
+
133
+ const downgradedNote = severityRank(severity) > severityRank(current)
134
+ ? `;本次 ${severity} 低于既有 ${current},按"只升不降"忽略`
135
+ : '';
136
+ console.log(`Already captured: ${rel}(内容一致,未重复写入${downgradedNote})`);
137
+ return { file: rel, phase, domain, type, severity, alreadyExisted: true };
95
138
  }
139
+ writeFileSync(resolved.filePath, content, 'utf-8');
140
+
141
+ // v0.57.0 §4.2:INDEX 由共享入口派生(capture 不再直接写 INDEX)
142
+ refreshIndex(dir, { quiet: true });
96
143
 
97
- console.log(`Captured: ${phase}/${fileName} (${severity}/${type})`);
98
- return { file: `${phase}/${fileName}`, phase, domain, type, severity };
144
+ console.log(`Captured: ${phase}/${resolved.fileName} (${severity}/${type})`);
145
+ return { file: `${phase}/${resolved.fileName}`, phase, domain, type, severity };
99
146
  }
100
147
 
101
148
  if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
@@ -0,0 +1,185 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * solutions-entry — 复利条目的**身份与定位**共享层(v0.57.0 P4)
4
+ *
5
+ * ── 为什么是共享层 ──────────────────────────────────────────────────────────
6
+ * 「条目身份 = 文件名 + 正文签名」是 **promote 与 capture 两条写入通道**共用的核心判据。
7
+ * v0.57.0 首轮只修了 promote 的碰撞保护,`capture` 仍是无条件 `writeFileSync`
8
+ * ⇒ 同日同 slug **静默覆盖**:丢正文、丢已升的 severity、丢 `confirmations`,
9
+ * 使 changelog ①「内容零丢失」在 capture 通道为**假**。
10
+ * 这是「同型缺陷只修了一条通道」的又一次复发,故提为共享层而非在两处各写一份。
11
+ *
12
+ * ── 身份键的两个分量(清洗口径相反)────────────────────────────────────────
13
+ * - **标题分量** `slug`:`cleanTitle` 清洗后的标题。序号是 change **局部**编号,
14
+ * 不同 change 的同一条经验序号必然不同 —— 不清洗则跨 change 复发**永远匹配不上**。
15
+ * - **内容分量** `entryBodySignature`:frontmatter 之后的正文。**不能**清洗 ——
16
+ * 清洗会抹掉同标题经验的区分度,让 `## 3. X` 与 `## 7. X`(正文不同)塌缩成一条。
17
+ */
18
+
19
+ import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs';
20
+ import { join, basename } from 'node:path';
21
+ import { normalizeInline } from './md-normalize.mjs';
22
+
23
+ /** 同标题碰撞时的后缀探测上限(防御畸形数据导致的死循环) */
24
+ export const MAX_COLLISION_SUFFIX = 20;
25
+
26
+ /**
27
+ * 剥离 YAML 行尾注释(空格 + `#` 起始)。
28
+ *
29
+ * 为什么必须是共享层:`templates/learnings.md` 的 frontmatter 范例**逐行都带行尾注释**
30
+ * (`severity: medium # critical | high | medium | low`),作者照抄即中招。不剥的后果
31
+ * 不是"显示难看"而是**静默错判**——
32
+ * - `severityRank("high # 注释")` 落到未知值档(排在 `low` **之后**)⇒ 高危经验被排到末尾、
33
+ * 在 `--limit` 窗口下最先被截掉
34
+ * - capture 的"只升不降"判据把 `high # 注释` 当未知值,于是 `medium` 被当成升级 ⇒ **降级**
35
+ * v0.57.0 P4 二轮评审实测:该剥离只落在 promote 一条读取路径,capture 与 index-gen 仍在裸读。
36
+ */
37
+ export function stripInlineComment(raw) {
38
+ return String(raw ?? '').replace(/\s+#.*$/, '').trim();
39
+ }
40
+
41
+ /**
42
+ * 取出 frontmatter 块文本(不含 `---` 分隔行);无则 null。
43
+ *
44
+ * **只认文件/段开头的块**(`^?---\n`,容忍 BOM)。这是身份判据的要害:
45
+ * 一旦放宽到"任意位置的 `---` 对",正文里的**水平线对**与**围栏内的 frontmatter 样例**
46
+ * 都会被当成元数据块 ⇒ 非条目文件被投影成幽灵条目。实测危害不止"多一行":
47
+ * 幽灵会以同名档位排到真条目前面,在 `maxEntries` 边界把**真条目挤出 INDEX**,
48
+ * 并进入 inject 的注入上下文(`/tmp/tfverify4/a-evict.mjs`、`a-inject-ghost.mjs`)。
49
+ *
50
+ * `learnings.md` 的条目 frontmatter 位于 `## 标题` 之后 —— 由**调用方先剔掉标题行**
51
+ * 使其落回段首(见 `solutions-promote.mjs` 的 `parseLearnings`),而不是在此放宽匹配:
52
+ * 放宽后无法区分"标题后的块"与"正文里的块",这正是 v0.57.0 三轮放过、四轮才抓住的根因。
53
+ */
54
+ export function frontmatterBlock(content) {
55
+ const m = String(content ?? '').match(/^?---\n([\s\S]*?)\n---/);
56
+ return m ? m[1] : null;
57
+ }
58
+
59
+ /**
60
+ * 解析 frontmatter → 扁平对象(值已剥行尾注释,键值均走 `normalizeInline` 容错
61
+ * 全角冒号 / 加粗 / 反引号)。无 frontmatter 块时返回 null。
62
+ *
63
+ * 消费方:`capture`(经 `readFrontmatterValue`)、`index-gen`、`cmd-doctor`,
64
+ * 以及 `promote` 的 `parseLearnings` 路径(经本文件的 `parseFrontmatter` 导入)。
65
+ *
66
+ * **已知例外**(有意不复用,理由见各自注释,勿当作遗漏):
67
+ * - `promote` 的 `confirmByFile` —— 需要**整块替换**(带 `---` 的完整匹配),故用严格起始锚
68
+ * 自取块 + 本地 `readScalar`(内部同样调 `stripInlineComment`)
69
+ * - `backfill` —— 只做**键存在性检测**与整块替换,不读值
70
+ *
71
+ * (此前 promote 与 index-gen 各有一份**读值**实现、语义还不同——index-gen 版不剥注释,
72
+ * 这正是上面 `stripInlineComment` 注释里那三个症状的来源。)
73
+ */
74
+ export function parseFrontmatter(content) {
75
+ const block = frontmatterBlock(content);
76
+ if (block === null) return null;
77
+ const fm = {};
78
+ for (const line of block.split('\n')) {
79
+ const idx = line.search(/[::]/);
80
+ if (idx <= 0) continue;
81
+ const key = normalizeInline(line.slice(0, idx));
82
+ if (key) fm[key] = normalizeInline(stripInlineComment(line.slice(idx + 1)));
83
+ }
84
+ // 没解析出任何键 ⇒ 空块(`---\n---`),不是元数据。返回 **null 而非 `{}`**:
85
+ // 后者是**真值**,会让调用方的 `if (!fm) continue` 失效。
86
+ return Object.keys(fm).length > 0 ? fm : null;
87
+ }
88
+
89
+ /** 读取单个 frontmatter 键(无则 null)。 */
90
+ export function readFrontmatterValue(content, key) {
91
+ const fm = parseFrontmatter(content);
92
+ return fm ? (fm[key] ?? null) : null;
93
+ }
94
+
95
+ /**
96
+ * 判定一段内容是否为**复利条目文件**(有 frontmatter 且含可见领域键)。
97
+ *
98
+ * 为什么必须是共享判据:`index-gen` 用它决定"索引什么",`cmd-doctor` 用它决定
99
+ * "什么算漂移"。两者判据不一致时会出现**执行修复命令也消不掉的告警**——
100
+ * 实测(v0.57.0 四轮):doctor 把阶段目录下的格式说明文档判为"文件存在、INDEX 无行"
101
+ * 并建议重跑 `index-gen`,而 index-gen 正确地忽略了该文件 ⇒ 告警永久存在,
102
+ * 比单纯的误报更误导(它把人支使去执行一个修不好的命令)。
103
+ */
104
+ export function isSolutionEntry(content) {
105
+ const fm = parseFrontmatter(content);
106
+ return !!(fm && (fm.phase || fm.date || fm.severity));
107
+ }
108
+
109
+ /**
110
+ * 列出某阶段目录下的**条目文件**(普通文件 + 内容是条目),返回 `{ file, filePath, rel, content }`。
111
+ *
112
+ * 为什么必须是共享层(v0.57.0 P4 收尾)——三个消费方各有同型暴露:
113
+ * 1. **判据不一致**会产生"重跑 index-gen 也消不掉的告警"(C.8 #125)
114
+ * 2. **不筛文件类型 + 裸读**会让**非普通文件**直接抛异常:目录名 `x.md`(EISDIR)、
115
+ * 断链 symlink(ENOENT)、无读权限(EACCES)、symlink 指向目录(EISDIR)。
116
+ * 在 `index-gen` 里是整批中断;在 `cmd-doctor` 里更糟——`checkSolutions` 内联在
117
+ * `checks` 数组字面量中、**无逐维度隔离** ⇒ 抛异常会**中止整个 doctor**(12 个维度全丢)。
118
+ * 故此处同时承担"判据"与"防御"两件事,调用方不必各写一份。
119
+ *
120
+ * 非普通文件与读失败一律**跳过**(而非抛出):它们本就不是可索引的内容,
121
+ * 让一个坏文件带走整轮巡检是不成比例的。
122
+ *
123
+ * @param {string} phaseDir 阶段目录(不存在或非目录时返回空数组)
124
+ * @returns {Array<{file: string, filePath: string, rel: string, content: string}>}
125
+ */
126
+ export function listSolutionEntries(phaseDir) {
127
+ if (!existsSync(phaseDir) || !statSync(phaseDir).isDirectory()) return [];
128
+ const phase = basename(phaseDir);
129
+ const out = [];
130
+ for (const d of readdirSync(phaseDir, { withFileTypes: true })) {
131
+ if (!d.isFile() || !d.name.endsWith('.md')) continue;
132
+ const filePath = join(phaseDir, d.name);
133
+ let content;
134
+ try {
135
+ content = readFileSync(filePath, 'utf-8');
136
+ } catch {
137
+ continue; // 断链 symlink / 无读权限 —— 跳过,不让单个坏文件带走整轮枚举
138
+ }
139
+ if (!isSolutionEntry(content)) continue;
140
+ out.push({ file: d.name, filePath, rel: `${phase}/${d.name}`, content });
141
+ }
142
+ return out;
143
+ }
144
+
145
+ /**
146
+ * 条目正文签名 —— 身份键的**内容分量**。
147
+ *
148
+ * 为什么需要它:`<date>-<slug>.md` 只是身份的**标题分量**,而不同经验可以同标题
149
+ * (`## 3. 契约漂移` 与 `## 7. 契约漂移` 是两条独立经验)。仅凭文件名把碰撞判为
150
+ * "同一条" ⇒ 后一条的正文与 severity 整条消失(隔离复现实证)。
151
+ *
152
+ * frontmatter 被剔除:severity/confirmations 随确认次数变化,不能进入身份。
153
+ * 空白规范化(`\s+` → 空格):容忍换行/尾随空格等纯格式差异。
154
+ */
155
+ export function entryBodySignature(content) {
156
+ return String(content ?? '')
157
+ .replace(/^---\n[\s\S]*?\n---\n*/, '')
158
+ .replace(/\s+/g, ' ')
159
+ .trim();
160
+ }
161
+
162
+ /**
163
+ * 定位本次经验对应的条目文件(身份 = 标题分量 + 内容分量)。
164
+ *
165
+ * - 文件不存在 → 新建(`isNew: true`)
166
+ * - 存在且签名相同 → **同一条经验**(`isNew: false`,交调用方决定"确认"还是"已捕获")
167
+ * - 存在但签名不同 → 是**另一条**同标题经验 → 探测 `-2`/`-3`… 直到签名匹配或遇空位
168
+ *
169
+ * @param {string} phaseDir 阶段目录
170
+ * @param {string} date `YYYY-MM-DD`
171
+ * @param {string} slug 标题分量(已 slugify)
172
+ * @param {string} signature 内容分量(`entryBodySignature` 的产出)
173
+ * @returns {{filePath: string, fileName: string, isNew: boolean}|null} null = 超出后缀上限
174
+ */
175
+ export function resolveEntryPath(phaseDir, date, slug, signature) {
176
+ for (let n = 1; n <= MAX_COLLISION_SUFFIX; n++) {
177
+ const fileName = n === 1 ? `${date}-${slug}.md` : `${date}-${slug}-${n}.md`;
178
+ const filePath = join(phaseDir, fileName);
179
+ if (!existsSync(filePath)) return { filePath, fileName, isNew: true };
180
+ if (entryBodySignature(readFileSync(filePath, 'utf-8')) === signature) {
181
+ return { filePath, fileName, isNew: false };
182
+ }
183
+ }
184
+ return null;
185
+ }