dsh-mastery-loop 0.1.0 → 0.2.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 (28) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/package.json +1 -1
  3. package/skills/mastery-loop/SKILL.md +22 -21
  4. package/skills/mastery-loop/exam-templates.md +102 -0
  5. package/skills/mastery-loop/scripts/_lib.mjs +408 -0
  6. package/skills/mastery-loop/scripts/aggregate.mjs +155 -0
  7. package/skills/mastery-loop/scripts/graph.mjs +448 -0
  8. package/skills/mastery-loop/scripts/ledger.mjs +223 -0
  9. package/skills/mastery-loop/scripts/rewrite-links.mjs +81 -0
  10. package/skills/mastery-loop/scripts/scan.mjs +276 -0
  11. package/skills/mastery-loop/scripts/validate.mjs +115 -59
  12. package/skills/mastery-loop/templates.md +210 -62
  13. package/skills/mastery-loop-setup/SKILL.md +105 -76
  14. package/skills/mastery-loop-setup/seeds/config.json +39 -0
  15. package/skills/mastery-loop-setup/seeds/error-taxonomy.json +4 -0
  16. package/skills/mastery-loop-setup/seeds/errors.jsonl +1 -0
  17. package/skills/mastery-loop-setup/seeds/graph.json +13 -0
  18. package/skills/mastery-loop-setup/seeds/ledger.jsonl +2 -0
  19. package/skills/mastery-loop-setup/seeds/moc.md +15 -0
  20. package/skills/mastery-loop-setup/seeds/note.md +33 -0
  21. package/skills/mastery-loop-setup/seeds/paper-format.json +15 -0
  22. package/skills/mastery-loop-setup/seeds/state.jsonl +2 -0
  23. package/skills/mastery-loop-setup/seeds/config.md +0 -34
  24. package/skills/mastery-loop-setup/seeds/error-taxonomy.md +0 -17
  25. package/skills/mastery-loop-setup/seeds/errors.md +0 -48
  26. package/skills/mastery-loop-setup/seeds/index.md +0 -25
  27. package/skills/mastery-loop-setup/seeds/ledger.md +0 -26
  28. package/skills/mastery-loop-setup/seeds/paper-format.md +0 -30
package/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0
4
+
5
+ **架构重写(不兼容)** —— 库的布局、文件格式、脚本边界全变了。已有的库要重新对齐一次。
6
+
7
+ **修复(自 0.1.0 起就坏的 bug)**:`mastery-loop-setup` 的 frontmatter 是**非法 YAML** —— `description` 写成裸标量,里面含 `mastery-loop: explore` 的「冒号 + 空格」。DSH 加载器对非法 frontmatter 的处理是**把整个 skill 丢弃**,所以 **`/mastery-loop-setup` 从 0.1.0 起就从未出现过**(模型侧、用户侧都没有),不是开关问题。已改成 `>-` 折行块标量(文字未改)。
8
+
9
+ **布局**:工作区根下两个并列文件夹 —— `kb/`(内容区,给人看)+ `sys/`(机器区,给脚本读)。
10
+
11
+ - `kb/knowledge/` 知识点 · `kb/MOC/` 聚合产物(镜像分类层级)
12
+ - `sys/config.json` · `sys/graph.json` · `sys/ledger.jsonl` · `sys/errors.jsonl` · `sys/error-taxonomy.json` · `sys/paper-format.json` · `sys/state.jsonl`
13
+ - `sys/changes.jsonl` 只在「没有 git 且用户不装 git」时启用
14
+
15
+ **知识点契约**
16
+
17
+ - `归属` = **来源文件**(多值、必填);**删 `课程:` / `主题:` / `难度:`** —— 课程与讲次改为**从 `归属` 解析**
18
+ - 正文固定六节,**删 `## 平行关联`**;`## 后置知识` 由图反算、不手写
19
+ - 链接一律**带路径、相对工作区根、带后缀、带别名**
20
+
21
+ **分类与范围**:分类是**全库级定义**(层数不固定、每层一条解析规则、类值经用户确认后写进 config);**范围 = 分类里的一个或多个类值**。
22
+
23
+ **关系图**:`sys/graph.json`(`用途` / `规则` / `coverage` / `edges`);边带 **≤30 字**理由;**后置由图反算**;**六项校验**(双向自洽 / 无自链 / 无悬空 / 同段无重复 / 前后置互斥 / 前置两两不可比)。
24
+
25
+ **聚合与两区**:`kb/MOC/` 的**机器区**由 `aggregate.mjs` 从「`归属` + 图」生成并覆盖;**手写区**(主线、脉络、考点提示)脚本**永不碰**。
26
+
27
+ **脚本边界**:脚本只做重复性、机械性、不需要变通的事(校验 / 渲染 / 聚合 / 哈希跳过 / 账本运算 / 批量写回);判断前置、写理由、出题、批改、**错误原因逐字去重**留给 agent。
28
+
29
+ **git 不硬依赖**:有 git 就用它承担历史(**不自动 commit、绝不 push**);没有且用户不装 → 回退 `sys/changes.jsonl`。
30
+
31
+ **其它**:删掉 `index.md`(索引 = 聚合结果的层级视图);Miyo 仍为**可选**、发布版不依赖。
32
+
3
33
  ## 0.1.0
4
34
 
5
35
  首个版本。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mastery-loop",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Opt-in DeepSeek Harness skill bundle: mastery-loop — an exam-revision loop that drills until every knowledge point reaches your target accuracy. / 通用应试复习闭环。",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -12,11 +12,13 @@ description: >-
12
12
 
13
13
  ---
14
14
 
15
- ## 0 · 三条硬规矩
15
+ ## 0 · 四条硬规矩
16
16
 
17
17
  1. **不主动扫库。** 初始化之后只在三种情况下才动知识库:① 读到**不符合要求**的文件 → 处理那**一个**;② 出现**新文件** → 处理;③ **用户主动调用** → 才做。
18
- 2. **笔记的定义部分不动。** 只写那几个记录文件(`ledger.md` / `errors.md` / `index.md` / `paper-format.md`),以及 MOC 的「知识点清单」。**知识点正文与 frontmatter 不碰。**
18
+ 2. **笔记的定义部分默认不动。** 平时只写 `sys/`(*机器区:账本 / 错误原因 / 关系图 / 状态*)与 `kb/MOC/` 的**机器区**。
19
+ **没有新增或修订知识点时不碰;当知识库的架构本身要改(如关系图重建)时,改写是正当的。**
19
20
  3. **不声称符合 ASD-STE100。** 错误原因**尽可能按 STE 的规则写**,这是借用它的原则,不是合规声明。
21
+ 4. **脚本只做机械重复的事。** 校验、渲染、聚合、哈希比对、账本运算交给 `scripts/`(Node,零依赖,`node xxx.mjs`);**判断谁是谁的前置、写理由、出题、批改、错误原因逐字去重**留给自己。**不自动 `commit`、绝不 `push`。**
20
22
 
21
23
  ---
22
24
 
@@ -24,11 +26,11 @@ description: >-
24
26
 
25
27
  ### 1.1 先确认初始化过
26
28
 
27
- 读 `config.md` → 找**知识库根目录** → 读 `ledger.md`。
29
+ 读 `sys/config.json` → 找**工作区根** → 读 `sys/ledger.jsonl`。
28
30
 
29
- - **`ledger.md` 不存在,或顶部没有「首次对齐」标记行** → **停下来**,告诉用户:
31
+ - **`sys/ledger.jsonl` 不存在** → **停下来**,告诉用户:
30
32
 
31
- > 这个知识库还没初始化过。先跑一次 `/mastery-loop-setup`,它会把 6 个文件建好,之后我再开工。
33
+ > 这个知识库还没初始化过。先跑一次 `/mastery-loop-setup`,它会把 `kb/` 与 `sys/` 建好,之后我再开工。
32
34
 
33
35
  **不要自己进初始化流程** —— 那件事归 `mastery-loop-setup`。
34
36
 
@@ -39,9 +41,9 @@ description: >-
39
41
  顺序固定:
40
42
 
41
43
  ```
42
- config.md(路径 + 每门课的成员选择 + 语言标准)
43
- → ledger.md(进度;标记行已确认)
44
- → paper-format.md(本次考试的格式)
44
+ sys/config.json(路径 + 分类 + 语言标准)
45
+ → sys/ledger.jsonl(进度)
46
+ → sys/paper-format.json(本次考试的格式)
45
47
  ```
46
48
 
47
49
  然后**把读到的状态摆出来**:
@@ -79,11 +81,11 @@ config.md(路径 + 每门课的成员选择 + 语言标准)
79
81
 
80
82
  ### 3.1 先给复习清单
81
83
 
82
- 按 `ledger.md` 排:**熟练度低的在前**,`NULL`(没学过)和「样本不足」标出来。
84
+ 按 `sys/ledger.jsonl` 排:**熟练度低的在前**,`NULL`(没学过)和「样本不足」标出来。
83
85
 
84
86
  ### 3.2 出题
85
87
 
86
- **卷子参数从 `paper-format.md` 查**对应考试的那一条 —— **不要从往年卷现抄**。
88
+ **卷子参数从 `sys/paper-format.json` 查**对应考试的那一条 —— **不要从往年卷现抄**。
87
89
  查不到 → 用**默认出卷法:判断题 + 每题说明理由**。
88
90
 
89
91
  配比(按本次范围的题池算,不按课程切分):
@@ -94,7 +96,7 @@ config.md(路径 + 每门课的成员选择 + 语言标准)
94
96
  | 30% | `是否学习过 = 否` 的点 |
95
97
  | 10% | `最后复习日期` 最久远的点(防遗忘) |
96
98
 
97
- 卷子模板 → [templates.md](templates.md#试卷模板)。
99
+ 卷子模板 → [exam-templates.md](exam-templates.md#试卷模板)。
98
100
 
99
101
  ### 3.3 每题后面必须挂两行
100
102
 
@@ -126,7 +128,7 @@ config.md(路径 + 每门课的成员选择 + 语言标准)
126
128
  2. **详细解析** —— 引用材料原文逻辑;计算题给完整中间步骤
127
129
  3. **薄弱点定位** —— 说清**为什么错**(从思维链里看),别笼统说「理解不到位」
128
130
 
129
- 判分口径 → [templates.md](templates.md#判分口径)。
131
+ 判分口径 → [exam-templates.md](exam-templates.md#判分口径)。
130
132
 
131
133
  ### 3.5 提炼错误原因(只在错 ≥1 时)
132
134
 
@@ -139,19 +141,18 @@ config.md(路径 + 每门课的成员选择 + 语言标准)
139
141
  - **动词开头**且**必须带对象**(禁 `wrong`、`didn't know`)
140
142
  - **指代不悬空**
141
143
 
142
- 然后**去 `errors.md` 里查**:
144
+ 然后**去 `sys/errors.jsonl` 里查**:
143
145
 
144
146
  1. **在该知识点分组内全量检索**
145
147
  2. 有**逐字一样**的 → **只追加日期**,**绝不重新措辞**
146
148
  3. 没有 → 新增一条
147
149
 
148
- ### 3.6 写回文件
150
+ ### 3.6 写回(顺序写死,不许反向;契约正文 → [templates.md](templates.md))
149
151
 
150
- - **`ledger.md`** —— 更新 `做对` / `总共出过` / `熟练程度` / `最后复习日期` / `是否学习过`。
151
- (`做对` 只在**思维链对且答案对**时 +1。)
152
- - **`errors.md`** —— 按上面的规则追加日期或新增条目(**先按知识点分组,再按错误原因分组**)。
153
- - **`index.md`** —— 有变动时**先改索引**。
154
- - **MOC 清单** —— 再从索引刷新受影响 MOC 的「## 知识点清单」。**永不反向。**
152
+ - **关系图** —— 本次**若新增或修订了知识点**:先改 `sys/graph.json`(逻辑定前置 → 写边与 ≤30 字理由),再由 `graph.mjs` 算后置并渲染 `## 前置知识` / `## 后置知识`,最后跑六项校验。**没动知识点就跳过这一步。**
153
+ - **账本** —— 你只输出字段(做对 / 总共出过 / 熟练程度 / 最后复习日期 / 是否学习过),由 `ledger.mjs` 写进 `sys/ledger.jsonl`。(`做对` 只在**思维链对且答案对**时 +1。)
154
+ - **错误原因** —— 逐字去重由**你**做(脚本会把「把 A1 和 B 弄混」和「把 A2 和 B 弄混」误判成重复),写完让脚本写进 `sys/errors.jsonl`。
155
+ - **`kb/MOC/` 的机器区** —— 跑 `aggregate.mjs` 从「`归属` + 图」聚合;**手写区一个字不动**。
155
156
 
156
157
  ### 3.7 达标与继续
157
158
 
@@ -171,13 +172,13 @@ config.md(路径 + 每门课的成员选择 + 语言标准)
171
172
 
172
173
  **从第一轮批改就开始攒。** 每轮出错,就把该点的 `规则 / ⚠️ 常见错误 / 🔑 锚` 三行补进去 —— 出错的那一刻正是信息最鲜活的时候。到复习结束时,它**已经**是成品。
173
174
 
174
- 模板 → [templates.md](templates.md#考前-list)。
175
+ 模板 → [exam-templates.md](exam-templates.md#考前-list)。
175
176
 
176
177
  ---
177
178
 
178
179
  ## 4 · 产出:考前 list
179
180
 
180
- **独立成文**(一篇笔记,与那 6 个文件并列)。**只收出过错的点**,按知识点一条,三行结构:
181
+ **独立成文**(一篇 Markdown 笔记,放在 `kb/` 下)。**只收出过错的点**,按知识点一条,三行结构:
181
182
 
182
183
  ```
183
184
  ### <知识点>
@@ -0,0 +1,102 @@
1
+ # 出题 · 判分 · 考前 list 模板
2
+
3
+ > **本文件是三个固定模板**:试卷骨架、判分口径、考前 list。契约正文(*库长什么样*)在 [templates.md](templates.md)。
4
+ > **按需查,别通读。**
5
+
6
+ ---
7
+
8
+ ## 试卷模板
9
+
10
+ > ⚠️ **参数从 `sys/paper-format.json` 查**对应考试的那一条 —— **不要从往年卷现抄**。
11
+ > 查不到 → 用**默认出卷法:判断题 + 每题说明理由**。
12
+
13
+ ```markdown
14
+ # PRACTICE EXAM PAPER — ROUND N
15
+
16
+ **Based on**: <paper-format.json 里的考试名>
17
+ **Time Allowed**: <抄自 paper-format.json>
18
+ **Total Marks**: <抄自 paper-format.json>
19
+
20
+ ---
21
+
22
+ ## Question 1 (<marks> marks)
23
+
24
+ <题干>
25
+
26
+ (a) <小问> (<marks> marks)
27
+ (b) ...
28
+
29
+ 答案:
30
+ 思维链:
31
+
32
+ ## Question 2 (<marks> marks)
33
+
34
+ ...
35
+
36
+ 答案:
37
+ 思维链:
38
+
39
+ **END OF PAPER**
40
+ ```
41
+
42
+ 硬性要求:
43
+
44
+ - **Round N** 每轮递增;**跨轮保持同一套卷子参数**,这样正确率才可比。
45
+ - **每道题后面必须挂 `答案:` / `思维链:` 两行** —— 选择题也要。
46
+ - 判断正误类小题,题干里就写明「**先判对错,再给理由**」。
47
+ - 分值分配**照 `sys/paper-format.json`**,不要自己发明比例。
48
+ - 卷尾之后**不得**出现任何答案、提示、「解析见下」的字样。
49
+
50
+ ---
51
+
52
+ ## 判分口径
53
+
54
+ | 题型 | 给分 | 扣分 / 零分 |
55
+ | --- | --- | --- |
56
+ | 判断正误 | 分类正确 + 解释命中关键词 | 只写 T/F 没解释 → 0;解释不引材料逻辑 → 减半 |
57
+ | 计算 | 结果正确 + 中间步骤齐全 | 结果对但无 working → 扣步骤分;结果错但步骤对 → 给步骤分 |
58
+ | 定义 | 关键词出现 + 给出具体示例 | 只背课本句式、无示例 → 不满分 |
59
+ | 画图 / 证明 | 图元齐全(轴标签、方向、边界)/ 每步有依据 | 图对但无标注 → 扣分 |
60
+
61
+ **总分与正确率按卷面原始分算**,不折算、不向上取整。
62
+
63
+ ### ⚠️ 与「做对」的区别
64
+
65
+ - **判分**用的是上面这张表(看答案)。
66
+ - **账本里的「做对」是另一回事**:必须 **思维链对 且 答案对**。
67
+ **蒙对**在评分表里可能得分,但在账本里**不算做对** —— 而且**要提炼一条错误原因**。
68
+
69
+ 批改纪律:
70
+
71
+ - **给分严格,只认关键词。** 别给「思路对了」这种虚分。
72
+ - 判错要**指到用户具体的句子**(在思维链里找),不要笼统说「理解不到位」。
73
+ - 也要指出**答得好**的部分 —— 避免用户误以为整块都薄弱。
74
+
75
+ ---
76
+
77
+ ## 考前 list
78
+
79
+ **独立成文**(一篇 Markdown 笔记,放在 `kb/` 下)。**只收出过错的点**,按知识点一条。
80
+
81
+ **从第一轮批改就开始攒** —— 每轮出错就把三行补进去,别等最后生成。
82
+
83
+ ```markdown
84
+ # 考前 list
85
+
86
+ ## Section A: <分类里的类值>
87
+
88
+ ### <知识点>
89
+ > **规则**:<正确说法>
90
+ > ⚠️ **常见错误**:<我实际犯的错 —— 与 sys/errors.jsonl 里的错误原因一致>
91
+ > 🔑 **锚**:<一句话记忆钩子 / 反例>
92
+
93
+ ## ⏱️ 考前 15 分钟速查
94
+ - <最致命的 N 条,一行一条>
95
+ ```
96
+
97
+ 要求:
98
+
99
+ - 一个知识点**只占三行**,超过说明还没提炼干净。
100
+ - **`⚠️ 常见错误` 必须与 `sys/errors.jsonl` 里的错误原因逐字一致** —— 那是同一份事实,不要换措辞。
101
+ - 锚点优先用**反例**或**极端情形**,不要口诀式废话。
102
+ - 涵盖**全部轮次**,不重复同一意思的条目。
@@ -0,0 +1,408 @@
1
+ #!/usr/bin/env node
2
+ // 共享工具(不单独使用)
3
+ //
4
+ // 用途:本文件是其它脚本共用的最小工具集 —— 读写 JSON / JSONL、解析 frontmatter、
5
+ // 收集知识点、扫 wikilink、替换 AUTO 区。**零依赖、不拼 shell。**
6
+
7
+ import { readFileSync, writeFileSync, existsSync, statSync, readdirSync, mkdirSync } from 'node:fs';
8
+ import { createHash } from 'node:crypto';
9
+ import { join, relative, dirname, sep } from 'node:path';
10
+
11
+ export const KB = 'kb';
12
+ export const NOTES = join('kb', 'knowledge');
13
+ export const MOC = join('kb', 'MOC');
14
+ export const SYS = 'sys';
15
+ export const AUTO_BEGIN = '<!-- AUTO:BEGIN -->';
16
+ export const AUTO_END = '<!-- AUTO:END -->';
17
+
18
+ export function readIfPossible(p) {
19
+ try {
20
+ return readFileSync(p, 'utf8');
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
25
+
26
+ export function writeText(p, text) {
27
+ mkdirSync(dirname(p), { recursive: true });
28
+ writeFileSync(p, text, 'utf8');
29
+ }
30
+
31
+ export function sha1(text) {
32
+ return 'sha1:' + createHash('sha1').update(text, 'utf8').digest('hex').slice(0, 16);
33
+ }
34
+
35
+ export function readJson(p) {
36
+ const t = readIfPossible(p);
37
+ if (t === null) return { ok: false, reason: `读不了:${p}` };
38
+ try {
39
+ return { ok: true, data: JSON.parse(t) };
40
+ } catch (err) {
41
+ return { ok: false, reason: `${p} 不是合法 JSON:${err.message}` };
42
+ }
43
+ }
44
+
45
+ /** JSONL:第一行是 header(kind: header),其余是数据行 */
46
+ export function readJsonl(p) {
47
+ const t = readIfPossible(p);
48
+ if (t === null) return { ok: false, reason: `读不了:${p}` };
49
+ const lines = t.split(/\r?\n/).filter((l) => l.trim());
50
+ const rows = [];
51
+ let header = null;
52
+ lines.forEach((l, i) => {
53
+ let obj;
54
+ try {
55
+ obj = JSON.parse(l);
56
+ } catch {
57
+ return;
58
+ }
59
+ if (i === 0 && obj && obj.kind === 'header') header = obj;
60
+ else rows.push(obj);
61
+ });
62
+ return { ok: true, header, rows };
63
+ }
64
+
65
+ export function writeJsonl(p, header, rows) {
66
+ const text = [JSON.stringify(header), ...rows.map((r) => JSON.stringify(r))].join('\n') + '\n';
67
+ writeText(p, text);
68
+ }
69
+
70
+ // ---------- frontmatter(只支持契约用到的那几种写法) ----------
71
+
72
+ /** 切出 frontmatter 文本与正文 */
73
+ export function splitFrontmatter(text) {
74
+ const m = /^---\r?\n([\s\S]*?)\r?\n---[ \t]*\r?\n?/.exec(text);
75
+ if (!m) return { fm: null, body: text };
76
+ return { fm: m[1], body: text.slice(m[0].length) };
77
+ }
78
+
79
+ function parseScalar(s) {
80
+ let v = s.trim();
81
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
82
+ v = v.slice(1, -1);
83
+ }
84
+ return v;
85
+ }
86
+
87
+ /** 行内列表:["a/b", "c"] —— 引号内的逗号/括号不切 */
88
+ function parseInlineList(s) {
89
+ const inner = s.trim().replace(/^\[/, '').replace(/\]$/, '');
90
+ const out = [];
91
+ let cur = '';
92
+ let quote = null;
93
+ for (let i = 0; i < inner.length; i++) {
94
+ const ch = inner[i];
95
+ if (quote) {
96
+ if (ch === quote) quote = null;
97
+ cur += ch;
98
+ continue;
99
+ }
100
+ if (ch === '"' || ch === "'") {
101
+ quote = ch;
102
+ cur += ch;
103
+ continue;
104
+ }
105
+ if (ch === ',') {
106
+ out.push(cur);
107
+ cur = '';
108
+ continue;
109
+ }
110
+ cur += ch;
111
+ }
112
+ if (cur.trim()) out.push(cur);
113
+ return out.map(parseScalar).filter((x) => x !== '');
114
+ }
115
+
116
+ /** 极简 YAML 子集 → 对象(够契约用) */
117
+ export function parseFrontmatter(fmText) {
118
+ const data = {};
119
+ if (!fmText) return data;
120
+ for (const line of fmText.split(/\r?\n/)) {
121
+ const m = /^([A-Za-z0-9_\u4e00-\u9fa5]+)\s*:\s*(.*)$/.exec(line);
122
+ if (!m) continue;
123
+ const key = m[1];
124
+ const raw = m[2].trim();
125
+ if (raw === '' || raw === '>-' || raw === '|') {
126
+ data[key] = '';
127
+ continue;
128
+ }
129
+ if (raw.startsWith('[')) data[key] = parseInlineList(raw);
130
+ else data[key] = parseScalar(raw);
131
+ }
132
+ return data;
133
+ }
134
+
135
+ // ---------- 知识点 ----------
136
+
137
+ export function collectNotes(root) {
138
+ const map = new Map();
139
+ const dir = join(root, NOTES);
140
+ const walk = (d, depth) => {
141
+ if (depth > 4) return;
142
+ let entries = [];
143
+ try {
144
+ entries = readdirSync(d, { withFileTypes: true });
145
+ } catch {
146
+ return;
147
+ }
148
+ for (const e of entries) {
149
+ if (e.name.startsWith('.')) continue;
150
+ const p = join(d, e.name);
151
+ if (e.isDirectory()) walk(p, depth + 1);
152
+ else if (/\.md$/i.test(e.name)) map.set(e.name.replace(/\.md$/i, ''), p);
153
+ }
154
+ };
155
+ walk(dir, 0);
156
+ return map;
157
+ }
158
+
159
+ export function noteName(raw) {
160
+ let s = String(raw || '').trim();
161
+ s = s.replace(/^\[\[/, '').replace(/\]\]$/, '');
162
+ const pipe = s.indexOf('|');
163
+ if (pipe !== -1) s = s.slice(0, pipe);
164
+ s = s.replace(/\.md$/i, '');
165
+ const parts = s.split(/[\\/]/);
166
+ return parts[parts.length - 1].trim();
167
+ }
168
+
169
+ /** 笔记名 + 分类值 + 一句话 */
170
+ export function readNote(root, name, path, cfg) {
171
+ const text = readIfPossible(path) || '';
172
+ const { fm, body } = splitFrontmatter(text);
173
+ const data = parseFrontmatter(fm);
174
+ const field = cfg?.分类?.字段 || '分类';
175
+ const csep = cfg?.分类?.分隔符 || '/';
176
+ const raw = data[field];
177
+ const 分类 = Array.isArray(raw) ? raw : raw ? [raw] : [];
178
+ const one = /##\s*一句话\s*\n+([^\n#]+)/.exec(body);
179
+ return {
180
+ name,
181
+ path,
182
+ rel: relative(root, path).split(sep).join('/'),
183
+ 分类: 分类.map((c) => String(c).split(csep).map((x) => x.trim()).filter(Boolean)),
184
+ 一句话: one ? one[1].trim() : '',
185
+ text,
186
+ data,
187
+ };
188
+ }
189
+
190
+ // ---------- 正文小节 ----------
191
+
192
+ export function sectionRange(text, heading) {
193
+ const re = new RegExp('^##\\s*' + heading + '\\s*$', 'm');
194
+ const m = re.exec(text);
195
+ if (!m) return null;
196
+ const after = text.indexOf('\n', m.index);
197
+ if (after === -1) return { headStart: m.index, bodyStart: text.length, end: text.length };
198
+ const rest = text.slice(after + 1);
199
+ const nextRel = rest.search(/^##[ \t]/m);
200
+ const end = nextRel === -1 ? text.length : after + 1 + nextRel;
201
+ return { headStart: m.index, bodyStart: after + 1, end };
202
+ }
203
+
204
+ export function sectionBullets(text, heading) {
205
+ const r = sectionRange(text, heading);
206
+ if (!r) return null;
207
+ const body = text.slice(r.bodyStart, r.end);
208
+ const names = [];
209
+ for (const line of body.split(/\r?\n/)) {
210
+ const mm = line.match(/^\s*[-*]\s+\[\[([^\]]+?)\]\]/);
211
+ if (mm) names.push(noteName(mm[1]));
212
+ }
213
+ return names;
214
+ }
215
+
216
+ export function replaceSection(text, heading, bullets) {
217
+ const r = sectionRange(text, heading);
218
+ if (!r) return { text, found: false };
219
+ const body = bullets.length ? bullets.join('\n') : '_(暂无)_';
220
+ return {
221
+ text: `${text.slice(0, r.headStart)}## ${heading}\n\n${body}\n\n${text.slice(r.end)}`,
222
+ found: true,
223
+ };
224
+ }
225
+
226
+ // ---------- AUTO 区 ----------
227
+
228
+ export function replaceAutoZone(text, lines) {
229
+ const b = text.indexOf(AUTO_BEGIN);
230
+ const e = text.indexOf(AUTO_END);
231
+ const block = `${AUTO_BEGIN}\n${lines.join('\n')}\n${AUTO_END}`;
232
+ if (b === -1 || e === -1 || e < b) {
233
+ // 没有标记:原地补一个,放在第一个 `## ` 之前(或文件末尾)
234
+ const at = text.search(/^##[ \t]/m);
235
+ const body = `${block}\n\n`;
236
+ return at === -1 ? text.trimEnd() + '\n\n' + body : text.slice(0, at) + body + text.slice(at);
237
+ }
238
+ return text.slice(0, b) + block + text.slice(e + AUTO_END.length);
239
+ }
240
+
241
+ // ---------- 链接 ----------
242
+
243
+ export function parseLinks(text) {
244
+ const out = [];
245
+ const re = /\[\[([^\]\n]+?)\]\]/g;
246
+ let m;
247
+ while ((m = re.exec(text))) {
248
+ const raw = m[1];
249
+ const pipe = raw.indexOf('|');
250
+ const target = (pipe === -1 ? raw : raw.slice(0, pipe)).trim();
251
+ const alias = pipe === -1 ? '' : raw.slice(pipe + 1).trim();
252
+ out.push({ raw, target, alias });
253
+ }
254
+ return out;
255
+ }
256
+
257
+ export function walkMarkdown(root, dirRel = '.', depth = 0, out = []) {
258
+ if (depth > 5) return out;
259
+ const dir = join(root, dirRel);
260
+ let entries = [];
261
+ try {
262
+ entries = readdirSync(dir, { withFileTypes: true });
263
+ } catch {
264
+ return out;
265
+ }
266
+ for (const e of entries) {
267
+ if (e.name.startsWith('.')) continue;
268
+ const rel = dirRel === '.' ? e.name : join(dirRel, e.name);
269
+ if (e.isDirectory()) walkMarkdown(root, rel, depth + 1, out);
270
+ else if (/\.md$/i.test(e.name)) out.push(join(root, rel));
271
+ }
272
+ return out;
273
+ }
274
+
275
+ export function existsDir(p) {
276
+ return existsSync(p) && statSync(p).isDirectory();
277
+ }
278
+
279
+ // ⚠️ 下面是**未被调用的副本**:图的单一实现是 `graph.mjs` 的 `check()`(`scan.mjs` 直接 import 它)。
280
+ // 保留只为对照;下次清理时删掉,别再往这里加逻辑。
281
+
282
+ export function checkGraph(root) {
283
+ const problems = [];
284
+ const warnings = [];
285
+ const g = readJson(join(root, SYS, 'graph.json'));
286
+ if (!g.ok) {
287
+ return { ok: false, reason: g.reason, problems, warnings, edges: [], notes: new Map(), involved: new Set() };
288
+ }
289
+ const data = g.data;
290
+ const edges = Array.isArray(data.edges) ? data.edges : [];
291
+ const notes = collectNotes(root);
292
+ const hasNote = (name) => notes.has(name);
293
+
294
+ const fwd = new Map();
295
+ const bwd = new Map();
296
+ const addTo = (m, k, v) => {
297
+ if (!m.has(k)) m.set(k, new Set());
298
+ m.get(k).add(v);
299
+ };
300
+
301
+ edges.forEach((e, i) => {
302
+ const at = `edges[${i}]`;
303
+ const from = noteName(e && e.from);
304
+ const to = noteName(e && e.to);
305
+ const reason = e && typeof e.reason === 'string' ? e.reason.trim() : '';
306
+ if (!from || !to) {
307
+ problems.push(`${at}:缺 from 或 to`);
308
+ return;
309
+ }
310
+ if (from === to) problems.push(`无自链 ✖ ${at}:${from} → ${to}`);
311
+ if (!hasNote(from)) problems.push(`无悬空 ✖ ${at}:找不到笔记「${from}」`);
312
+ if (!hasNote(to)) problems.push(`无悬空 ✖ ${at}:找不到笔记「${to}」`);
313
+ if (!reason) problems.push(`${at}:缺 reason(每条边必须 ≤30 字理由)`);
314
+ else if ([...reason].length > 30) problems.push(`${at}:reason ${[...reason].length} 字 > 30(${reason})`);
315
+ addTo(fwd, from, to);
316
+ addTo(bwd, to, from);
317
+ });
318
+
319
+ const involved = new Set([...fwd.keys(), ...bwd.keys()]);
320
+
321
+ for (const [name, path] of notes) {
322
+ const text = readIfPossible(path);
323
+ if (text === null) {
324
+ warnings.push(`读不了:${path}`);
325
+ continue;
326
+ }
327
+ const pre = sectionBullets(text, '前置知识');
328
+ const post = sectionBullets(text, '后置知识');
329
+ if (involved.has(name)) {
330
+ if (pre === null) warnings.push(`「${name}」缺 \`## 前置知识\` 小节`);
331
+ if (post === null) warnings.push(`「${name}」缺 \`## 后置知识\` 小节`);
332
+ }
333
+ for (const [label, list] of [['前置知识', pre], ['后置知识', post]]) {
334
+ if (!list) continue;
335
+ const seen = new Set();
336
+ for (const n of list) {
337
+ if (seen.has(n)) problems.push(`同段无重复 ✖ 「${name}」\`## ${label}\` 里「${n}」出现两次`);
338
+ seen.add(n);
339
+ }
340
+ }
341
+ if (pre && post) {
342
+ const both = [...new Set(pre.filter((n) => post.includes(n)))];
343
+ if (both.length) problems.push(`前后置互斥 ✖ 「${name}」同时出现在前置与后置:${both.join('、')}`);
344
+ }
345
+ if (pre) {
346
+ for (const n of new Set(pre)) {
347
+ if (!(bwd.get(name) || new Set()).has(n)) {
348
+ problems.push(`双向自洽 ✖ 「${name}」的 \`## 前置知识\` 里有「${n}」,但图里没有这条边`);
349
+ }
350
+ }
351
+ }
352
+ if (post) {
353
+ for (const n of new Set(post)) {
354
+ if (!(fwd.get(name) || new Set()).has(n)) {
355
+ problems.push(`双向自洽 ✖ 「${name}」的 \`## 后置知识\` 里有「${n}」,但图里没有这条边`);
356
+ }
357
+ }
358
+ }
359
+ }
360
+
361
+ for (const [from, tos] of fwd) {
362
+ for (const to of tos) {
363
+ const t1 = notes.has(from) ? readIfPossible(notes.get(from)) : null;
364
+ if (t1 !== null) {
365
+ const post = sectionBullets(t1, '后置知识') || [];
366
+ if (!post.includes(to)) problems.push(`双向自洽 ✖ 「${from}」的 \`## 后置知识\` 缺「${to}」(图里有这条边)`);
367
+ }
368
+ const t2 = notes.has(to) ? readIfPossible(notes.get(to)) : null;
369
+ if (t2 !== null) {
370
+ const pre = sectionBullets(t2, '前置知识') || [];
371
+ if (!pre.includes(from)) problems.push(`双向自洽 ✖ 「${to}」的 \`## 前置知识\` 缺「${from}」(图里有这条边)`);
372
+ }
373
+ }
374
+ }
375
+
376
+ const reach = (start) => {
377
+ const seen = new Set([start]);
378
+ const stack = [...(fwd.get(start) || [])];
379
+ while (stack.length) {
380
+ const cur = stack.pop();
381
+ if (seen.has(cur)) continue;
382
+ seen.add(cur);
383
+ for (const t of fwd.get(cur) || []) if (!seen.has(t)) stack.push(t);
384
+ }
385
+ seen.delete(start);
386
+ return seen;
387
+ };
388
+ for (const name of involved) {
389
+ const path = notes.get(name);
390
+ if (!path) continue;
391
+ const list = sectionBullets(readIfPossible(path) || '', '前置知识');
392
+ if (!list) continue;
393
+ const uniq = [...new Set(list)];
394
+ for (let i = 0; i < uniq.length; i++) {
395
+ for (let j = i + 1; j < uniq.length; j++) {
396
+ const a = uniq[i];
397
+ const b = uniq[j];
398
+ if (reach(a).has(b)) problems.push(`前置两两不可比 ✖ 「${name}」的前置里「${a}」已经是「${b}」的前置 —— 只收直接前置`);
399
+ else if (reach(b).has(a)) problems.push(`前置两两不可比 ✖ 「${name}」的前置里「${b}」已经是「${a}」的前置 —— 只收直接前置`);
400
+ }
401
+ }
402
+ }
403
+
404
+ if (data['用途'] === undefined) warnings.push('graph.json 缺「用途」字段');
405
+ if (data['规则'] === undefined) warnings.push('graph.json 缺「规则」字段');
406
+
407
+ return { ok: true, data, edges, notes, involved, problems, warnings, fwd, bwd };
408
+ }