acmer-coach 2.4.0 → 2.6.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/README.md CHANGED
@@ -20,7 +20,7 @@
20
20
 
21
21
  ## 它到底做了什么
22
22
 
23
- 30 个工具,三条轴:知识图、比赛能力、训练证据。
23
+ 31 个工具,三条轴:知识图、比赛能力、训练证据。
24
24
 
25
25
  | 轴 | 含义 |
26
26
  |---|---|
@@ -49,6 +49,7 @@ coach_schedule → coach_plan 排课
49
49
  coach_target → coach_vp_import → coach_postmortem → coach_focus
50
50
  ↓ 比赛目标、VP 复盘、当前重点
51
51
  coach_scope 适用范围、延伸、反例、退出条件
52
+ coach_note 续课笔记:教到哪里、尚待确认什么、下次接哪里
52
53
  ```
53
54
 
54
55
  `coach_wiki` 是知识来源:讲任何知识点之前先查本地 OI Wiki,**别凭记忆讲**。
@@ -72,6 +73,9 @@ VP 记录格式是 JSON 数组,每场至少包含 `eventId`、`contest`、`dat
72
73
  `coach_scope` 会返回一个能力或知识点的适用范围、延伸、反例、常见错误、迁移题
73
74
  和退出条件。没有人工范围卡片时它会明确拒绝编造,避免把“讲过”误当成“会用”。
74
75
 
76
+ 能力排名只是取舍参考。排在前三之外、尚未映射到目标能力的节点也能布置,由小鲸解释
77
+ 为什么现在练;课程版本、当前阶段和真实前置的检查继续保留。
78
+
75
79
  ### 长期学习路线
76
80
 
77
81
  `coach_curriculum assess` 从已有检测、代码判因、做题记录和日程实际完成中列出
@@ -117,6 +121,32 @@ learned/verified 记录,或路线中在此前安排的必验训练,不能从
117
121
 
118
122
  详见 [长期课程机制](docs/长期课程机制.md)。
119
123
 
124
+ ### 2.6:独立验收、灵活训练与逐题依据
125
+
126
+ 章节分数及限时通过仍记为 `passed`,但只有本次做出的题都明确报告 `independent: true`,才新增 `verified` 和验证段位。独立性未知或有帮助时,保留实际成绩,不能自动认证;旧证书保留,不补造独立事实。独立性来自明确记录,不是工具能从 AC 猜出的结论。延迟保持与迁移仍由既有课程 `exitChecks` 单独验收。
127
+
128
+ 训练沿用 `coach_assign` / `coach_log` / `coach_plan`:短诊断和整场 VP 可以直接绑定比赛能力,时长由教练明确给出;旧知识点练习和默认时长继续支持。计划只记录安排,不证明训练完成。
129
+
130
+ `coach_target profiles` 和 `coach_focus` 返回逐题 `samples` 与 `coverage`。中国亚洲区域赛主配置包含南京 2022 全卷十三题和 2023 六题,每题列模型、能力和命题组解题页;NWERC 十三题仅作扩展参考,WF 的三题仍是题面诊断。西安目录已经核对,题解未获取,算法样本数如实为零。覆盖数字不产生专题频率、奖牌线或预测;小鲸结合个人证据决定重点。
131
+
132
+ ### 2.5:接着教,判断仍归小鲸
133
+
134
+ `coach_note` 只做一件事:把一个知识点的教学判断和接续点留在现有
135
+ `PROGRESS.yaml` 的 `nodes.<id>.lessonNotes`。`read` 查看最近笔记,`write` 保存
136
+ `summary` 与 `nextStep`;不新增课程状态机、聊天数据库或另一份掌握记录。
137
+ `coach_status` 的 `teachingContext` 和每轮首步状态会带上短笔记,需要原话时再查原生会话。
138
+ 未指定节点时,读取优先显示最近有笔记的未完动作,再看游标;没有游标则看最近笔记。
139
+ 写入默认使用游标,显式 `node` 优先,不会为了写笔记移动游标。
140
+
141
+ `studentQuote` 可选。填写时核对当前 dsh 会话中的真实学生消息,自动记录来源;
142
+ 插件消息、压缩摘要和其他会话不能冒充学生回答。核对原话存在不等于判断学生理解,
143
+ 没有原话的笔记也可保存,但标作教练判断。笔记不会改进度状态、游标、待办或检测结果。
144
+ 旧宿主没有原生会话查询时,普通笔记可用,带原话的写入会明确拒绝。
145
+
146
+ `teachMinutes` 现在只保存讲授计划,不再自动写“已经教过”的日期。实际进入学习由
147
+ 教练显式 `coach_mark studying` 记录;已有接触记录继续可用,但不证明理解。
148
+ 这版验证的是记录、来源和续课机制;真实小鲸对话的教学效果仍需观察。
149
+
120
150
  ### 2.4:证据、验收与教学依据
121
151
 
122
152
  `coach_focus` 分开近30天的独立成功、待确认观察、已确认短板,按不同场次计数。
@@ -250,11 +280,12 @@ dsh 的会话上下文有限 —— 新开一个会话,教练不记得上次
250
280
 
251
281
  | 文件 | 作用 |
252
282
  |---|---|
253
- | `index.js` | 插件本体(host 端,21 个工具 + 规则注入) |
283
+ | `index.js` | 插件本体(host 端,31 个工具 + 规则注入) |
254
284
  | `lib/setup.js` | 环境搭建 + CF 公开数据同步 |
255
285
  | `lib/curriculum.js` | 持久课程路线、阶段验收门槛、版本与修订历史 |
256
286
  | `lib/curriculum-evidence.js` | 从实际记录派生证据引用与事件身份,去重课表和训练记录 |
257
287
  | `lib/curriculum-planning.js` | 阶段编排依据、前置顺序与进入阶段后的新证据检查 |
288
+ | `lib/teaching.js` | 节点续课笔记,复用进度文件与 dsh 原生会话查询 |
258
289
  | `lib/client.js` | 浏览器端:会话头部「技能树」按钮 + 弹层 |
259
290
  | `package.json` | 声明 `dsh.bundle.patch` + `dsh.client` |
260
291
  | `cordis.patch.yml` | bundle 层的 insert 行 |
@@ -276,7 +307,7 @@ dsh 的会话上下文有限 —— 新开一个会话,教练不记得上次
276
307
  |---|---|---|
277
308
  | **规则** | `assets/rules/coach-rules.md` | 插件用 `systemPrompt.section()` 注进 system prompt(和人设同一条路)。**改它要重启 dsh** |
278
309
  | **知识** | 地图 4 份 + OI Wiki 检索库 | `coach_setup init` 铺到数据目录;OI Wiki 直接从包里读 |
279
- | **工具** | 21 个 | 装完就在 |
310
+ | **工具** | 31 个 | 装完就在 |
280
311
 
281
312
  规则进的是 **system prompt,不是消息流**:行为约束每轮都得在场,
282
313
  而消息流会被长对话把尾巴挤掉。文本是**静态**的(只在加载时读一次)——
@@ -2,17 +2,69 @@
2
2
  version: 1
3
3
  profiles:
4
4
  - id: regional-diagnostic
5
- title: 区域赛通用能力诊断(有限样本)
5
+ title: 中国亚洲区域赛能力诊断(南京 2022/2023 样本)
6
+ scope: 以中国亚洲区域赛原题和命题组题解建立模型诊断,结合本人和团队的 VP 事实取舍。
7
+ status: provisional
8
+ sources:
9
+ - {title: SUA 南京 2022 命题组中文题解(全卷十三题), url: 'https://sua.ac/wiki/2022-icpc-nanjing/tutorial-sketch-zh.pdf'}
10
+ - {title: SUA 南京 2023 命题组题解(QOJ 镜像,采样六题), url: 'https://universal-cup-website.qoj.ac/files/ucup/tutorials/tutorials-2-11.pdf'}
11
+ - {title: QOJ 西安历年比赛目录(对应赛区的原题入口), url: 'https://qoj.ac/category/87'}
12
+ competencyIds: [contest-reading, contest-decision, implementation-stability, dp-modeling, graph-modeling, data-structures, math-combinatorics, code-review, team-communication]
13
+ samples:
14
+ - {year: 2022, contest: Nanjing, problem: I, model: 从旋转回文条件推出字符频次计数, competencyIds: [contest-reading, implementation-stability], solutionReviewed: true, sourceUrl: 'https://sua.ac/wiki/2022-icpc-nanjing/tutorial-sketch-zh.pdf#page=3'}
15
+ - {year: 2022, contest: Nanjing, problem: G, model: 维护总和及数量的可反悔贪心, competencyIds: [math-combinatorics, implementation-stability], solutionReviewed: true, sourceUrl: 'https://sua.ac/wiki/2022-icpc-nanjing/tutorial-sketch-zh.pdf#page=5'}
16
+ - {year: 2022, contest: Nanjing, problem: D, model: 二分阈值与有效操作区间前缀统计, competencyIds: [contest-reading, data-structures], solutionReviewed: true, sourceUrl: 'https://sua.ac/wiki/2022-icpc-nanjing/tutorial-sketch-zh.pdf#page=7'}
17
+ - {year: 2022, contest: Nanjing, problem: B, model: 单调队列优化 DP 与前后缀复用, competencyIds: [dp-modeling, data-structures], solutionReviewed: true, sourceUrl: 'https://sua.ac/wiki/2022-icpc-nanjing/tutorial-sketch-zh.pdf#page=10'}
18
+ - {year: 2022, contest: Nanjing, problem: A, model: 整体移动转边界与二维前缀和, competencyIds: [contest-reading, data-structures], solutionReviewed: true, sourceUrl: 'https://sua.ac/wiki/2022-icpc-nanjing/tutorial-sketch-zh.pdf#page=15'}
19
+ - {year: 2022, contest: Nanjing, problem: M, model: 局部极小与水平边界的分类计数, competencyIds: [math-combinatorics, implementation-stability], solutionReviewed: true, sourceUrl: 'https://sua.ac/wiki/2022-icpc-nanjing/tutorial-sketch-zh.pdf#page=19'}
20
+ - {year: 2022, contest: Nanjing, problem: J, model: 等式变二分图边与连通块配对, competencyIds: [graph-modeling], solutionReviewed: true, sourceUrl: 'https://sua.ac/wiki/2022-icpc-nanjing/tutorial-sketch-zh.pdf#page=22'}
21
+ - {year: 2022, contest: Nanjing, problem: E, model: 树形 DP 与虚树深度统计, competencyIds: [dp-modeling, graph-modeling], solutionReviewed: true, sourceUrl: 'https://sua.ac/wiki/2022-icpc-nanjing/tutorial-sketch-zh.pdf#page=25'}
22
+ - {year: 2022, contest: Nanjing, problem: C, model: 逆序操作与通配位置的贪心构造, competencyIds: [contest-reading, implementation-stability], solutionReviewed: true, sourceUrl: 'https://sua.ac/wiki/2022-icpc-nanjing/tutorial-sketch-zh.pdf#page=28'}
23
+ - {year: 2022, contest: Nanjing, problem: H, model: 树上边贡献与凹性背包合并优化, competencyIds: [dp-modeling, data-structures], solutionReviewed: true, sourceUrl: 'https://sua.ac/wiki/2022-icpc-nanjing/tutorial-sketch-zh.pdf#page=33'}
24
+ - {year: 2022, contest: Nanjing, problem: K, model: 堆排列概率与完全二叉树对称计数, competencyIds: [math-combinatorics], solutionReviewed: true, sourceUrl: 'https://sua.ac/wiki/2022-icpc-nanjing/tutorial-sketch-zh.pdf#page=36'}
25
+ - {year: 2022, contest: Nanjing, problem: L, model: 链上弦覆盖与二边割的增量维护, competencyIds: [graph-modeling, data-structures], solutionReviewed: true, sourceUrl: 'https://sua.ac/wiki/2022-icpc-nanjing/tutorial-sketch-zh.pdf#page=45'}
26
+ - {year: 2022, contest: Nanjing, problem: F, model: 锐角三角剖分的基例与递增构造, competencyIds: [math-combinatorics], solutionReviewed: true, sourceUrl: 'https://sua.ac/wiki/2022-icpc-nanjing/tutorial-sketch-zh.pdf#page=48'}
27
+ - {year: 2023, contest: Nanjing, problem: I, model: 按重置时间排序并检查约束区间一致性, competencyIds: [contest-reading, implementation-stability], solutionReviewed: true, sourceUrl: 'https://universal-cup-website.qoj.ac/files/ucup/tutorials/tutorials-2-11.pdf#page=3'}
28
+ - {year: 2023, contest: Nanjing, problem: F, model: 最后写入者依赖 DAG 与拓扑序交换, competencyIds: [graph-modeling], solutionReviewed: true, sourceUrl: 'https://universal-cup-website.qoj.ac/files/ucup/tutorials/tutorials-2-11.pdf#page=5'}
29
+ - {year: 2023, contest: Nanjing, problem: C, model: 异或计数同余与区间分解,题名不是算法标签, competencyIds: [math-combinatorics], solutionReviewed: true, sourceUrl: 'https://universal-cup-website.qoj.ac/files/ucup/tutorials/tutorials-2-11.pdf#page=7'}
30
+ - {year: 2023, contest: Nanjing, problem: G, model: 交换论证结合前缀选择与后缀背包, competencyIds: [dp-modeling, data-structures], solutionReviewed: true, sourceUrl: 'https://universal-cup-website.qoj.ac/files/ucup/tutorials/tutorials-2-11.pdf#page=9'}
31
+ - {year: 2023, contest: Nanjing, problem: A, model: 双位置可达状态与相对移动约束, competencyIds: [graph-modeling], solutionReviewed: true, sourceUrl: 'https://universal-cup-website.qoj.ac/files/ucup/tutorials/tutorials-2-11.pdf#page=11'}
32
+ - {year: 2023, contest: Nanjing, problem: L, model: 降序楼层贪心与放松下界的匹配证明, competencyIds: [math-combinatorics, contest-reading], solutionReviewed: true, sourceUrl: 'https://universal-cup-website.qoj.ac/files/ucup/tutorials/tutorials-2-11.pdf#page=13'}
33
+ selectionGuidance:
34
+ - 中国亚洲区域赛优先使用对应赛区和年份的真题;先诊断读题、模型选择和实现,不按 CF rating 连续解锁知识点。
35
+ - 结合本人 VP 已确认的失败原因选择本周重点;一道成功题可作为起点,不证明同模型已经掌握。
36
+ - I/G 用于条件提炼与贪心证明,D/A 用于变量转化和统计,B 用于从朴素转移推导优化;模型对应题解,训练取舍由教练决定。
37
+ - 西安目标优先看西安配置及对应原题;NWERC 只作扩展参考,不用欧洲样本代替中国赛区依据。
38
+ limitations:
39
+ - 当前采样南京 2022 全卷十三题和 2023 六题,未做跨年份与跨赛区专题频率统计;不能推出中国区域赛频率或本届西安预测。
40
+ - 这不是完整考纲,模型是教学解释而非唯一解法;验收阈值需按个人和团队事实调整。
41
+ - 未核验目标届次奖牌榜单,不给出金牌题数或奖牌保证。
42
+ - id: nwerc-reference
43
+ title: 区域赛通用能力诊断(NWERC 2024 全卷样本)
6
44
  scope: 用公开区域赛的题面与官方解题建立诊断任务,再结合本人 VP 证据取舍。
7
45
  status: provisional
8
46
  sources:
9
- - title: NWERC 2024 官方题面(采样 A、D、E、F、K、L)
47
+ - title: NWERC 2024 官方题面(A–M 全卷)
10
48
  url: https://2024.nwerc.eu/main/problem-set.pdf
11
- - title: NWERC 2024 官方解题(A/D/E/F/K/L 对应页)
49
+ - title: NWERC 2024 官方解题(A–M 对应页)
12
50
  url: https://2024.nwerc.eu/main/solutions.pdf
13
51
  - title: ICPC NCNA 公布的区域赛规则(通用背景,不提供专题频率)
14
52
  url: https://nc.na.icpc.global/rules/
15
53
  competencyIds: [contest-reading, contest-decision, implementation-stability, dp-modeling, graph-modeling, data-structures, math-combinatorics, code-review, team-communication]
54
+ samples:
55
+ - {year: 2024, contest: NWERC, problem: A, model: 自定义排序规则, competencyIds: [contest-reading, implementation-stability], solutionReviewed: true, sourceUrl: 'https://2024.nwerc.eu/main/solutions.pdf#page=4'}
56
+ - {year: 2024, contest: NWERC, problem: B, model: 周期标号游走与状态动态规划,题名不能决定算法, competencyIds: [graph-modeling, dp-modeling], solutionReviewed: true, sourceUrl: 'https://2024.nwerc.eu/main/solutions.pdf#page=24'}
57
+ - {year: 2024, contest: NWERC, problem: C, model: 网格极端点收缩与几何分类, competencyIds: [math-combinatorics, graph-modeling], solutionReviewed: true, sourceUrl: 'https://2024.nwerc.eu/main/solutions.pdf#page=19'}
58
+ - {year: 2024, contest: NWERC, problem: D, model: 排序后按最小元素分类计数与背包, competencyIds: [dp-modeling, math-combinatorics], solutionReviewed: true, sourceUrl: 'https://2024.nwerc.eu/main/solutions.pdf#page=11'}
59
+ - {year: 2024, contest: NWERC, problem: E, model: 下标变换与模幂, competencyIds: [math-combinatorics], solutionReviewed: true, sourceUrl: 'https://2024.nwerc.eu/main/solutions.pdf#page=8'}
60
+ - {year: 2024, contest: NWERC, problem: F, model: 单调栈和并查集跳过已满位置, competencyIds: [data-structures], solutionReviewed: true, sourceUrl: 'https://2024.nwerc.eu/main/solutions.pdf#page=13'}
61
+ - {year: 2024, contest: NWERC, problem: G, model: 双连通分量与排列奇偶性, competencyIds: [graph-modeling, math-combinatorics], solutionReviewed: true, sourceUrl: 'https://2024.nwerc.eu/main/solutions.pdf#page=31'}
62
+ - {year: 2024, contest: NWERC, problem: H, model: 交互函数迭代的等式构造,存在多种解法, competencyIds: [graph-modeling, math-combinatorics], solutionReviewed: true, sourceUrl: 'https://2024.nwerc.eu/main/solutions.pdf#page=16'}
63
+ - {year: 2024, contest: NWERC, problem: I, model: 素数指数参数化与容斥预处理, competencyIds: [math-combinatorics], solutionReviewed: true, sourceUrl: 'https://2024.nwerc.eu/main/solutions.pdf#page=28'}
64
+ - {year: 2024, contest: NWERC, problem: J, model: 独立优化与最近更高障碍限制, competencyIds: [contest-reading, implementation-stability], solutionReviewed: true, sourceUrl: 'https://2024.nwerc.eu/main/solutions.pdf#page=7'}
65
+ - {year: 2024, contest: NWERC, problem: K, model: 最短路与按距离排序的概率期望, competencyIds: [graph-modeling, math-combinatorics], solutionReviewed: true, sourceUrl: 'https://2024.nwerc.eu/main/solutions.pdf#page=15'}
66
+ - {year: 2024, contest: NWERC, problem: L, model: 单调可行性与贪心配对二分, competencyIds: [contest-reading, implementation-stability], solutionReviewed: true, sourceUrl: 'https://2024.nwerc.eu/main/solutions.pdf#page=10'}
67
+ - {year: 2024, contest: NWERC, problem: M, model: 期望线性性与面积前后缀求和,检查整数溢出, competencyIds: [math-combinatorics, implementation-stability], solutionReviewed: true, sourceUrl: 'https://2024.nwerc.eu/main/solutions.pdf#page=22'}
16
68
  selectionGuidance:
17
69
  - 先用整场 VP 诊断读题、实现、调试与分工,再用经确认的失败原因选择专题;成功题用作能力基线。
18
70
  - 采样 A 检查从精确排序规则到实现的转化;D 检查计数状态;E 检查大次数变换与模幂,题名不决定算法。
@@ -20,9 +72,9 @@ profiles:
20
72
  - 赛前优先修复已证实、可在剩余时间完成验收的障碍;未证实的缺口先安排短诊断,不直接扩知识点。
21
73
  - 同一题允许多种正确解法;把能解释条件并独立迁移作为验收,不能将某个算法标签当唯一答案。
22
74
  limitations:
23
- - 当前算法样本只有 NWERC 2024 的六题,不能推出中国区域赛频率、任何场次金牌题数或西安出题预测。
75
+ - 当前算法样本只有 NWERC 2024 的一届全卷十三题,不能推出中国区域赛频率、任何场次金牌题数或西安出题预测。
24
76
  - 读题时间盒、换题条件、验收动作是插件教学设计,需要按个人与团队记录调整,规则来源不证明这些训练阈值。
25
- - 未采样其余题或跨年份统计;此配置不是西安考纲,排名与奖牌目标仍需对应比赛榜单。
77
+ - 未做跨年份统计;此配置不是西安考纲,排名与奖牌目标仍需对应比赛榜单。模型映射是教学解释,官方解题不证明该题在其他赛区高频。
26
78
  - id: wf-extension
27
79
  title: WF 扩展能力诊断(题面样本)
28
80
  scope: 在基础能力稳定后,以 World Finals 原题检查跨模型推理、约束解释和交互实现。
@@ -33,6 +85,10 @@ profiles:
33
85
  - title: ICPC 2024 交互题评测说明(输出刷新等要求)
34
86
  url: https://docs.icpc.global/wp-content/uploads/2024/08/2024-JudgingNotes.pdf
35
87
  competencyIds: [contest-reading, contest-decision, dp-modeling, graph-modeling, data-structures, math-combinatorics, code-review, team-communication]
88
+ samples:
89
+ - {year: 2024, contest: World Finals, problem: B, model: 概率对象与事件依赖诊断,题解尚未核验, competencyIds: [math-combinatorics], solutionReviewed: false, sourceUrl: 'https://icpc.global/worldfinals/problems/2024-ICPC-World-Finals/icpc2024.pdf'}
90
+ - {year: 2024, contest: World Finals, problem: I, model: 树上覆盖目标建模,题解尚未核验, competencyIds: [graph-modeling], solutionReviewed: false, sourceUrl: 'https://icpc.global/worldfinals/problems/2024-ICPC-World-Finals/icpc2024.pdf'}
91
+ - {year: 2024, contest: World Finals, problem: L, model: 交互信息状态诊断,策略尚未核验, competencyIds: [contest-reading], solutionReviewed: false, sourceUrl: 'https://icpc.global/worldfinals/problems/2024-ICPC-World-Finals/icpc2024.pdf'}
36
92
  selectionGuidance:
37
93
  - 采样 B 用于检查概率对象、重复元素与事件依赖的精确表达;不能仅凭概率题标签选一个模板。
38
94
  - 采样 I 用于检查树上覆盖目标与最坏响应时间的建模,要求先解释判定或优化方案再实现。
@@ -52,6 +108,7 @@ profiles:
52
108
  - title: ICPC NCNA 公布的区域赛规则(通用背景,非西安本届规则)
53
109
  url: https://nc.na.icpc.global/rules/
54
110
  competencyIds: [contest-reading, contest-decision, implementation-stability, code-review, team-communication]
111
+ samples: []
55
112
  selectionGuidance:
56
113
  - 使用本人和当前团队 VP 的经确认障碍安排短期动作,专题需求暂参考区域赛通用诊断并明示来源。
57
114
  - 先核验目标届次、原始题面、主办方题解和榜单,再记录逐题模型、可替代解法及样本年份。
@@ -1335,7 +1335,7 @@ const SNAP = 5;
1335
1335
  // 块型短名。**必须和 host 的 SCHED_KINDS[k].name 逐字一致** ——
1336
1336
  // 不一致时页面会拿 id 兜底显示(不会崩),但那块看着就像坏了。
1337
1337
  // host 是真相源,这里只管显示。verify.mjs 有一条盯着这个一致性。
1338
- const KIND_NAME = { cycle: "题目循环", review: "复习一题", exam: "章节检测" };
1338
+ const KIND_NAME = { cycle: "题目循环", review: "复习一题", exam: "章节检测", diagnostic: "短诊断", vp: "完整 VP" };
1339
1339
 
1340
1340
  const minToHM = (m) => {
1341
1341
  const t = HOUR_FROM * 60 + m;
@@ -1447,7 +1447,7 @@ function renderSched() {
1447
1447
  const kind = String(b.kind ?? "");
1448
1448
  // 地图改过名、甚至删过节点,而计划是历史数据 ——
1449
1449
  // 查不到名字就退回 id,**不能因为查不到就不画**(那等于计划凭空消失)。
1450
- const nm = byId(b.node)?.name ?? String(b.node ?? "");
1450
+ const nm = b.competency ? `能力:${b.competency}` : byId(b.node)?.name ?? String(b.node ?? "");
1451
1451
  const size = mins >= 75 ? "s-full" : mins >= 40 ? "s-mid" : "s-mini";
1452
1452
  // 中等高度(40~74 分钟,循环块正好 60)挤不下两行,但**时间要留住** ——
1453
1453
  // busy 那边中档只放事由就够(他自己知道标了什么),
@@ -1457,7 +1457,7 @@ function renderSched() {
1457
1457
  ? `<span class="t">${esc(b.from)}–${esc(b.to)}</span>${esc(nm)}`
1458
1458
  : size === "s-mid" ? `${esc(b.from)} ${esc(nm)}` : "";
1459
1459
  const tip = `${b.from}–${b.to} ${KIND_NAME[kind] ?? kind} ${nm}` +
1460
- (kind === "cycle" ? " (40 自己做 → 10 解决遗留 → 10 重写)" : "");
1460
+ (kind === "cycle" && mins === 60 ? " (40 自己做 → 10 解决遗留 → 10 重写)" : "");
1461
1461
  return `<div class="pbar k-${esc(kind)} ${size}" title="${esc(tip)}" ` +
1462
1462
  `style="top:${pct(a)};height:${pct(mins)}">${body}</div>`;
1463
1463
  }).join("");
@@ -13,6 +13,8 @@
13
13
  比赛目标不能悄悄覆盖长期目标。赛前在剩余实际容量内选择可验收的改善,赛后再按新
14
14
  证据修订;日期只提供约束,不证明某专题该学。无法装进预算时说明暂缓什么与代价。
15
15
  * `coach_target profiles` 提供有来源和样本限制的配置,再按需设置 `profileId`。
16
+ `samples` 是逐题模型与来源,`coverage` 是已核验覆盖;零样本就是没有依据。根据题目
17
+ 条件选模型,不能从题名或一次样本计数推出必考、高频或完整考纲。
16
18
  provisional 表示有限样本或尚待取样,不能称官方考纲、预测本届高频专题或保证奖牌。
17
19
  不因某知识点“低频”就永久排除,不把同一道题的某一种解法当唯一算法标签。
18
20
  * 原始 VP 判题、耗时和提交是观察;WA 不等于不会,AC 不证明独立完成。先用
@@ -22,7 +24,9 @@
22
24
  * `coach_focus` 只汇总近30天不同场次:成功起点不算问题,重复提交/复盘不增加场次数,
23
25
  修改旧复盘不把它变成最近发生。分数是取舍参考,不是掌握度、出题概率或奖牌概率。
24
26
 
25
- * 有比赛目标时,先调 `coach_target read`,再调 `coach_focus`。没有目标时不能用
27
+ * 有比赛目标时,结合 `coach_target read` 和 `coach_focus` 的信息取舍;当前上下文已有
28
+ 足够信息时不必重复查询,也不要求固定调用顺序。能力排名是参考,排在前三之外或尚未
29
+ 映射到能力的节点可以训练,说明理由即可。没有目标时不能用
26
30
  rating、游标或地图后继猜长期方向;先建立比赛、日期、结果目标、团队模式、时间预算
27
31
  和能力优先级。
28
32
  * `coach_focus` 给的是比赛能力重点,不是知识点清单。每次训练必须说明它服务哪个能力、
@@ -92,9 +96,10 @@
92
96
 
93
97
  下面九条是硬规则,每条都能机械检查。违反就是没做好本职工作:
94
98
 
95
- 1. **给训练指令前,必须先调 `coach_target`/`coach_focus`,再调 `coach_next`**。
96
- 不许只凭 rating 或地图后继挑知识点 ——
97
- 它可能在地图里根本不存在,或者前置还没满足。
99
+ 1. **给训练指令前,核对目标、当前重点和地图前置**。
100
+ 用 `coach_target`、`coach_focus`、`coach_next` 按需补足信息;上下文已有足够依据时
101
+ 不重复读取,不规定调用顺序。不许只凭 rating 或地图后继挑知识点,
102
+ 节点必须真实存在,前置必须满足。
98
103
  2. **每条指令必须带地图节点 id**。节点 id 只能从地图取到,
99
104
  所以这条会自动逼你先读地图。
100
105
  3. **一次只给一个动作。** 禁止候选列表、禁止「你可以考虑…」、
@@ -186,16 +191,18 @@
186
191
 
187
192
  ### 复习别把新知识挤没
188
193
 
189
- 这套系统的价值在**往前推**,不在原地保温。一个月的表里排满复习,
190
- 等于这个月没往前走。
194
+ 训练要改善目标比赛中的表现。新知识、复习和迁移都可能带来改善,
195
+ 安排哪一个要看目标、已有表现和投入收益。
191
196
 
192
197
  **但他自己会想先复习** —— 复习舒服(做过的东西有把握)、
193
198
  开新知识点难受(进入状态有成本)。所以这个平衡**得你把**,
194
199
  不能等他主动说"我想学新的":
195
200
 
196
201
  * 排课时,复习块**按火候排**(放得最久的那几个才排),不是按"他提了哪个"排;
197
- * 比火候更靠前的是**推进**:只要他状态还行、时间够,块里就该有一个是开新知识点的;
198
- * 反过来说,**火候高到离谱的(放了一两个月)也不能不管** —— 那种是该重讲,不是该复习。
202
+ * **推进不等于开新知识点**:按目标与实际表现选择新课、复习、迁移或 VP,
203
+ 不为填课表硬开新点,也不让熟悉题反复练习代替真实改善;
204
+ * **放了一两个月的知识点先核对保持情况**,再决定检测、复习或重讲,
205
+ 不把间隔长直接当作已经忘光。
199
206
  * **提法按规则 3:一次一个动作** ——「这块我建议验一下,90 分钟三题,做不做?」
200
207
  并且**说清你为什么觉得该验了**(拿上面的记录当证据)。
201
208
  他说做,你才走下面三步。
@@ -339,9 +346,16 @@
339
346
  他说会了就别再给。
340
347
  * **注释密度跟他的样例走** —— 样例里注释少就少,别逐行注释。
341
348
 
342
- **讲完才出题**:讲完用 `coach_assign` 布置,**带上 `teachMinutes`** ——
343
- 没有那一段它会被拒绝(新知识点不给 `teachMinutes` 就是又回到"直接出题"了)。
344
- 讲多久**你定**,别参考任何固定值:看你判断他需要多少。
349
+ **新课动作要留出教学时间**:用 `coach_assign` 布置时带 `teachMinutes`,
350
+ 它是计划时长,不证明已经讲过,也不证明学生理解。实际开始学习后可用 `coach_mark`
351
+ 记为 studying;是否学会仍需学生表现和检测。讲多久由你根据需要判断。
352
+
353
+ **续课只留必要笔记**:发生值得接着讲的进展或中断时,用 `coach_note write` 保存
354
+ 当前判断、尚未确认之处和下一次接续点,不记录整段聊天、不每次回复都写。
355
+ `coach_status` 和新会话状态会带上最近笔记,需要细节再用 `coach_note read`。
356
+ 引用学生回应可填 `studentQuote`;工具只核对当前会话里真实学生原话,身份和位置自动记录,
357
+ 不要让学生提供消息 ID。核对成功只证明原话存在,不证明理解正确。没有原话依据的笔记
358
+ 仍可保存,但必须当作教练判断;笔记不改游标、进度、待办或检测结果,不替代掌握证据。
345
359
 
346
360
  ⚠️ **例外:游标自己那个节点不算"新知识点"** —— 接着做,不逼你再讲一遍。
347
361
  **但例外有条件:这个节点得真讲过**(有教学痕迹)。游标可以被挪到一个
@@ -390,6 +404,12 @@
390
404
 
391
405
  ### 二、验收:什么才算"会了"
392
406
 
407
+ * `coach_grade` 的 `passed` 只表示卷面分数和限时通过;本次做出题的 `independent`
408
+ 全部明确为 true 才新增 `verified`。未知就留未知,提示或题解帮助不能补成独立。
409
+ 节点 verified 不是保持率或迁移能力证明;这些按课程现有 `exitChecks` 分别验收。
410
+ 短诊断和整场 VP 可用 `coach_assign` 绑定真实 competency,再用 `coach_log` 记录
411
+ 实际交付物与独立性;计划、时长和一次交付物通过都不证明该能力全面掌握。
412
+
393
413
  * 只有 **无提示 + 换皮题(同知识点、他没练过的题)+ 间隔 ≥1 天** 的表现才算数;
394
414
  带提示做对的,**不计入"已验证"**。
395
415
  依据:延迟 1–6 天后测的效应 g=0.82,当天测只有 0.56(Adesope 2017,272 个效应量)。
@@ -457,7 +477,8 @@
457
477
 
458
478
  ## 训练数据操作纪律(P0)
459
479
 
460
- * 修改训练数据(地图、进度、日程)前,必须:① 告知将改什么 ② 等确认后执行。
480
+ * 已授权课程内的事实记录、续课笔记和做题用时正常保存,不重复要求确认。
481
+ 替换或删除个人数据、大幅改变目标或课程时,先说明将改什么,等确认后执行。
461
482
  * 禁止擅自修改他人的训练数据。
462
483
  * 挑题之前先查 AC 记录,不重复喂已经会的题。
463
484
 
@@ -506,8 +527,8 @@
506
527
  (一个写、一个验、一个造 hack 数据)。
507
528
  末尾并行死磕多道难题是 ICPC 最常见的败因 —— 风险是最后一道都做不出。
508
529
  * 每场记一句:**题目是不是全都被读过**(读漏题比做不出更亏)。
509
- * **赛前 2 周不塞新技巧**:只确认已经练到"不用想"的东西 ——
510
- 压力下只有自动化了的技能不掉线。
530
+ * **临近比赛通常优先巩固与模拟**:按剩余时间、掌握基础和预期收益取舍;
531
+ 必要的新内容可安排,说明收益、风险和验收方式,不按固定两周一律禁止。
511
532
 
512
533
  ## 紧急停止(最高优先级)
513
534
 
@@ -1335,7 +1335,7 @@ const SNAP = 5;
1335
1335
  // 块型短名。**必须和 host 的 SCHED_KINDS[k].name 逐字一致** ——
1336
1336
  // 不一致时页面会拿 id 兜底显示(不会崩),但那块看着就像坏了。
1337
1337
  // host 是真相源,这里只管显示。verify.mjs 有一条盯着这个一致性。
1338
- const KIND_NAME = { cycle: "题目循环", review: "复习一题", exam: "章节检测" };
1338
+ const KIND_NAME = { cycle: "题目循环", review: "复习一题", exam: "章节检测", diagnostic: "短诊断", vp: "完整 VP" };
1339
1339
 
1340
1340
  const minToHM = (m) => {
1341
1341
  const t = HOUR_FROM * 60 + m;
@@ -1447,7 +1447,7 @@ function renderSched() {
1447
1447
  const kind = String(b.kind ?? "");
1448
1448
  // 地图改过名、甚至删过节点,而计划是历史数据 ——
1449
1449
  // 查不到名字就退回 id,**不能因为查不到就不画**(那等于计划凭空消失)。
1450
- const nm = byId(b.node)?.name ?? String(b.node ?? "");
1450
+ const nm = b.competency ? `能力:${b.competency}` : byId(b.node)?.name ?? String(b.node ?? "");
1451
1451
  const size = mins >= 75 ? "s-full" : mins >= 40 ? "s-mid" : "s-mini";
1452
1452
  // 中等高度(40~74 分钟,循环块正好 60)挤不下两行,但**时间要留住** ——
1453
1453
  // busy 那边中档只放事由就够(他自己知道标了什么),
@@ -1457,7 +1457,7 @@ function renderSched() {
1457
1457
  ? `<span class="t">${esc(b.from)}–${esc(b.to)}</span>${esc(nm)}`
1458
1458
  : size === "s-mid" ? `${esc(b.from)} ${esc(nm)}` : "";
1459
1459
  const tip = `${b.from}–${b.to} ${KIND_NAME[kind] ?? kind} ${nm}` +
1460
- (kind === "cycle" ? " (40 自己做 → 10 解决遗留 → 10 重写)" : "");
1460
+ (kind === "cycle" && mins === 60 ? " (40 自己做 → 10 解决遗留 → 10 重写)" : "");
1461
1461
  return `<div class="pbar k-${esc(kind)} ${size}" title="${esc(tip)}" ` +
1462
1462
  `style="top:${pct(a)};height:${pct(mins)}">${body}</div>`;
1463
1463
  }).join("");
@@ -2,6 +2,11 @@ import test from 'node:test'
2
2
  import assert from 'node:assert/strict'
3
3
  import { readFileSync } from 'node:fs'
4
4
  import YAML from 'yaml'
5
+ import { mkdtempSync, rmSync } from 'node:fs'
6
+ import { tmpdir } from 'node:os'
7
+ import { join } from 'node:path'
8
+ import { fileURLToPath } from 'node:url'
9
+ import { createStrategyStore } from '../lib/strategy.js'
5
10
 
6
11
  const knowledge = new URL('../assets/knowledge/', import.meta.url)
7
12
  const read = name => YAML.parse(readFileSync(new URL(name, knowledge), 'utf8'))
@@ -24,6 +29,37 @@ function textList(value, label) {
24
29
  assert.ok(value.every(item => typeof item === 'string' && item.trim()), `${label}: text required`)
25
30
  }
26
31
 
32
+ test('contest evidence exposes actual reviewed samples, never a complete syllabus', () => {
33
+ const dir = mkdtempSync(join(tmpdir(), 'coach-samples-'))
34
+ try {
35
+ const store = createStrategyStore({ dataDir: dir, competenciesPath: fileURLToPath(new URL('COMPETENCIES.yaml', knowledge)) })
36
+ const profiles = store.target('profiles').availableProfiles
37
+ const regional = profiles.find(profile => profile.id === 'regional-diagnostic')
38
+ assert.equal(regional.coverage.sampledProblems, 19)
39
+ assert.equal(regional.coverage.reviewedSolutions, 19)
40
+ assert.deepEqual(regional.coverage.years, [2022, 2023])
41
+ assert.equal(regional.coverage.isSyllabus, false)
42
+ assert.deepEqual(regional.samples.filter(sample => sample.year === 2022).map(sample => sample.problem).sort(), [...'ABCDEFGHIJKLM'])
43
+ assert.equal(new Set(regional.samples.map(sample => `${sample.contest}:${sample.year}:${sample.problem}`)).size, 19)
44
+ assert.match(regional.title, /中国|亚洲/)
45
+ for (const sample of regional.samples) {
46
+ assert.ok(sample.model && sample.solutionReviewed)
47
+ assert.match(sample.sourceUrl, sample.year === 2022
48
+ ? /^https:\/\/sua\.ac\/wiki\/2022-icpc-nanjing\/tutorial-sketch-zh\.pdf#page=\d+$/
49
+ : /^https:\/\/universal-cup-website\.qoj\.ac\/files\/ucup\/tutorials\/tutorials-2-11\.pdf#page=\d+$/)
50
+ assert.ok(sample.competencyIds.every(id => competencies.some(card => card.id === id)))
51
+ }
52
+ const xian = profiles.find(profile => profile.id === 'xian-pending')
53
+ assert.equal(xian.coverage.sampledProblems, 0)
54
+ assert.equal(xian.coverage.reviewedSolutions, 0)
55
+ assert.equal(xian.coverage.isSyllabus, false)
56
+ assert.equal(store.target('set', { expectedRevision: 0, contest: '区域赛', date: '2026-10-17', result: '稳定', profileId: regional.id, weeklyHours: 25,
57
+ priorities: [{ id: 'graph-modeling', weight: 10 }] }).ok, true)
58
+ assert.equal(store.focus().profile.coverage.sampledProblems, 19)
59
+ assert.equal(profiles.find(profile => profile.id === 'nwerc-reference').coverage.reviewedSolutions, 13)
60
+ } finally { rmSync(dir, { recursive: true, force: true }) }
61
+ })
62
+
27
63
  test('node teaching cards use exact map nodes and contain sourced transfer boundaries', () => {
28
64
  const catalog = read('NODE_SCOPES.yaml')
29
65
  assert.equal(catalog.version, 1)
@@ -9,7 +9,7 @@ function shape(schema, value) {
9
9
  assert.ok(value && typeof value === 'object' && !Array.isArray(value))
10
10
  if (schema.additionalProperties === false) for (const key of Object.keys(value)) assert.ok(key in schema.properties, `unexpected field ${key}`)
11
11
  for (const [key, field] of Object.entries(schema.properties ?? {})) {
12
- if (field.required) assert.ok(key in value, `missing field ${key}`)
12
+ if (field.required === true || (Array.isArray(schema.required) && schema.required.includes(key))) assert.ok(key in value, `missing field ${key}`)
13
13
  if (key in value) shape(field, value[key])
14
14
  }
15
15
  } else if (schema.type === 'array') {
@@ -38,6 +38,7 @@ test('registered coaching tools expose cited findings, sourced teaching and comp
38
38
  assert.ok(tools.get('coach_postmortem').parameters.properties.findings)
39
39
  assert.ok(tools.get('coach_focus').output.schema.properties.profile.properties?.id, 'SDK needs explicit nested profile fields')
40
40
  assert.ok(tools.get('coach_vp_import').parameters.properties.events.items.properties.measurements)
41
+ assert.equal((await call('coach_grade', { results: [] })).ok, false, 'an absent paper has a complete refusal shape')
41
42
  assert.ok((await call('coach_target', { action: 'profiles' })).availableProfiles.length)
42
43
  const missing = await call('coach_postmortem', { eventId: 'unknown', findings: [] })
43
44
  assert.equal(missing.ok, false)
@@ -91,3 +92,128 @@ test('registered coaching tools expose cited findings, sourced teaching and comp
91
92
  rmSync(dir, { recursive: true, force: true })
92
93
  }
93
94
  })
95
+
96
+ test('a planned lecture is not reported as past teaching', async () => {
97
+ const dir = mkdtempSync(join(tmpdir(), 'coach-lecture-plan-'))
98
+ const previous = { data: process.env.COACH_DATA_DIR, db: process.env.COACH_DB }
99
+ try {
100
+ writeFileSync(join(dir, 'MAP.yaml'), 'meta: {version: 1}\nnodes:\n - {id: A, name: 基础, depends: []}\n - {id: B, name: 新课, depends: [A]}\n')
101
+ writeFileSync(join(dir, 'PROGRESS.yaml'), 'version: 1\ncursor: A\nnodes:\n A: {status: learned}\n')
102
+ process.env.COACH_DATA_DIR = dir
103
+ process.env.COACH_DB = join(dir, 'unused.db')
104
+ const { apply } = await import('../index.js?lecture-plan-contract')
105
+ const tools = new Map()
106
+ apply({ tools: { register: tool => tools.set(tool.name, tool) }, inject() {}, on() {} })
107
+ const assigned = await tools.get('coach_assign').execute({ node: 'B', deliverable: '独立写出状态与转移', teachMinutes: 20, why: '先解释状态为何保留这些信息' })
108
+ assert.equal(assigned.accepted, true, assigned.reject)
109
+ assert.equal((await tools.get('coach_set_cursor').execute({ node: 'B' })).ok, true)
110
+ const status = await tools.get('coach_status').execute({})
111
+ assert.equal(status.cursorTaught, '', 'planning teaching must not create a teaching date')
112
+ assert.equal(status.pendingActions.find(item => item.node === 'B').teachMinutes, 20)
113
+ } finally {
114
+ for (const [key, value] of [['COACH_DATA_DIR', previous.data], ['COACH_DB', previous.db]]) {
115
+ if (value === undefined) delete process.env[key]
116
+ else process.env[key] = value
117
+ }
118
+ rmSync(dir, { recursive: true, force: true })
119
+ }
120
+ })
121
+
122
+ test('teaching notes survive reload, reach the native hook and never certify mastery', async () => {
123
+ const dir = mkdtempSync(join(tmpdir(), 'coach-note-contract-'))
124
+ const previous = { data: process.env.COACH_DATA_DIR, db: process.env.COACH_DB }
125
+ try {
126
+ writeFileSync(join(dir, 'MAP.yaml'), 'meta: {version: 1}\nnodes:\n - {id: A, name: 基础, depends: []}\n')
127
+ writeFileSync(join(dir, 'PROGRESS.yaml'), 'version: 1\ncursor: A\nnodes: {}\n')
128
+ process.env.COACH_DATA_DIR = dir
129
+ process.env.COACH_DB = join(dir, 'unused.db')
130
+ const quote = '这里的状态只需要保留容量,不需要保留选取顺序。'
131
+ const query = { readSession: async (id) => {
132
+ assert.equal(id, 'session-1')
133
+ return { session: { id }, inheritedEventCount: 0, events: [{ type: 'user/message', seq: 7, data: {
134
+ id: 'message-7', role: 'user', source: { kind: 'user' }, content: [{ type: 'text', text: quote }],
135
+ } }] }
136
+ } }
137
+ const register = async (suffix) => {
138
+ const tools = new Map(), hooks = new Map()
139
+ const { apply } = await import(`../index.js?note-contract-${suffix}`)
140
+ apply({ tools: { register: tool => tools.set(tool.name, tool) }, inject() {},
141
+ get: service => service === 'sessionQuery' ? query : undefined,
142
+ on: (event, handler) => hooks.set(event, handler) })
143
+ return { tools, hooks }
144
+ }
145
+ const first = await register('first')
146
+ const note = first.tools.get('coach_note')
147
+ assert.ok(note, 'one native tool must expose teaching continuation')
148
+ const exec = { agent: { session: { id: 'session-1' } } }
149
+ const plain = await note.execute({ action: 'write', summary: '准备续讲状态含义;尚未核对学生回应', nextStep: '先查看已有的课堂上下文' })
150
+ shape(note.output.schema, plain)
151
+ assert.equal(plain.ok, true, plain.reject)
152
+ assert.ok(!('studentEvidence' in plain.notes[0]), 'unquoted notes do not invent student evidence')
153
+ const saved = await note.execute({ action: 'write', summary: '学生能解释容量维,但迁移能力尚未核对', nextStep: '用另一个背包变式确认状态含义', studentQuote: quote }, exec)
154
+ shape(note.output.schema, saved)
155
+ assert.equal(saved.ok, true, saved.reject)
156
+ assert.equal(saved.notes.at(-1).studentEvidence.messageId, 'message-7')
157
+ const bad = await note.execute({ action: 'write', summary: '不存在的回应', nextStep: '继续核对', studentQuote: '这句话不在学生的消息中' }, exec)
158
+ shape(note.output.schema, bad)
159
+ assert.equal(bad.ok, false)
160
+ const second = await register('reload')
161
+ const read = await second.tools.get('coach_note').execute({ action: 'read' })
162
+ shape(second.tools.get('coach_note').output.schema, read)
163
+ assert.deepEqual(read.notes, saved.notes)
164
+ assert.equal(read.total, 2)
165
+ const status = await second.tools.get('coach_status').execute({})
166
+ shape(second.tools.get('coach_status').output.schema, status)
167
+ assert.equal(status.verifiedCount, 0)
168
+ assert.equal(status.learnedCount, 0)
169
+ assert.equal(status.cursorTaught, '', 'even a real student quote cannot become a teaching/contact certificate')
170
+ assert.match(status.teachingContext, /背包变式/)
171
+ const hook = second.hooks.get('agent/pre-step')
172
+ const decision = { kind: 'enter', messages: [] }
173
+ const injected = await hook({ step: 1, messages: [] }, async () => decision)
174
+ const message = injected.messages.at(-1)
175
+ assert.equal(message.source.kind, 'plugin:acmer-coach', 'format v4 requires producer-owned source kind')
176
+ assert.ok(message.content.some(block => block.type === 'text' && block.text.includes('背包变式')))
177
+ assert.deepEqual(await hook({ step: 2, messages: [] }, async () => decision), decision)
178
+ } finally {
179
+ for (const [key, value] of [['COACH_DATA_DIR', previous.data], ['COACH_DB', previous.db]]) {
180
+ if (value === undefined) delete process.env[key]
181
+ else process.env[key] = value
182
+ }
183
+ rmSync(dir, { recursive: true, force: true })
184
+ }
185
+ })
186
+
187
+ test('continuation follows a noted unfinished lesson while the cursor stays on its prerequisite', async () => {
188
+ const dir = mkdtempSync(join(tmpdir(), 'coach-note-pending-'))
189
+ const previous = { data: process.env.COACH_DATA_DIR, db: process.env.COACH_DB }
190
+ try {
191
+ writeFileSync(join(dir, 'MAP.yaml'), 'meta: {version: 1}\nnodes:\n - {id: A, name: 基础, depends: []}\n - {id: B, name: 新课, depends: [A]}\n')
192
+ writeFileSync(join(dir, 'PROGRESS.yaml'), 'version: 1\ncursor: A\nnodes:\n A: {status: learned}\n')
193
+ process.env.COACH_DATA_DIR = dir
194
+ process.env.COACH_DB = join(dir, 'unused.db')
195
+ const tools = new Map(), hooks = new Map()
196
+ const { apply } = await import('../index.js?note-pending-contract')
197
+ apply({ tools: { register: tool => tools.set(tool.name, tool) }, inject() {}, on: (event, handler) => hooks.set(event, handler) })
198
+ const assigned = await tools.get('coach_assign').execute({ node: 'B', deliverable: '解释状态含义', teachMinutes: 20, why: '沿着已学前置开始新课' })
199
+ assert.equal(assigned.accepted, true, assigned.reject)
200
+ assert.equal((await tools.get('coach_note').execute({ action: 'write', node: 'B', summary: '状态含义已解释,尚未检查学生回应', nextStep: '继续核对容量维度' })).ok, true)
201
+ const status = await tools.get('coach_status').execute({})
202
+ assert.equal(status.cursor, 'A', 'showing continuity must not move the cursor')
203
+ assert.match(status.teachingContext, /容量维度/)
204
+ const latest = await tools.get('coach_note').execute({ action: 'read' })
205
+ assert.equal(latest.node, 'B')
206
+ assert.equal(latest.total, 1)
207
+ const explicit = await tools.get('coach_note').execute({ action: 'read', node: 'A' })
208
+ assert.equal(explicit.node, 'A', 'explicit node selection overrides the context fallback')
209
+ assert.equal(explicit.total, 0)
210
+ const injected = await hooks.get('agent/pre-step')({ step: 1, messages: [] }, async () => ({ kind: 'enter', messages: [] }))
211
+ assert.ok(injected.messages.at(-1).content.some(block => block.type === 'text' && block.text.includes('容量维度')))
212
+ } finally {
213
+ for (const [key, value] of [['COACH_DATA_DIR', previous.data], ['COACH_DB', previous.db]]) {
214
+ if (value === undefined) delete process.env[key]
215
+ else process.env[key] = value
216
+ }
217
+ rmSync(dir, { recursive: true, force: true })
218
+ }
219
+ })
@@ -53,7 +53,7 @@ function validateShape(schema, value) {
53
53
  assert.equal(typeof value, 'object'); assert.notEqual(value, null)
54
54
  if (schema.additionalProperties === false) for (const key of Object.keys(value)) assert.ok(key in schema.properties, `unexpected ${key}`)
55
55
  for (const [key, child] of Object.entries(schema.properties)) {
56
- if (child.required) assert.ok(key in value, `missing ${key}`)
56
+ if (child.required === true || (Array.isArray(schema.required) && schema.required.includes(key))) assert.ok(key in value, `missing ${key}`)
57
57
  if (key in value) validateShape(child, value[key])
58
58
  }
59
59
  } else if (schema.type === 'array') {
@@ -17,6 +17,24 @@ const practice = (extra = {}) => ({ date, problemId: '1234A', solved: false,
17
17
  independent: true, minutes: 40, ...extra })
18
18
  const progress = (nodeRecord, node = '区间 DP') => ({ nodes: { [node]: nodeRecord } })
19
19
  const rowsFor = (record) => collectEvidence(progress(record))
20
+
21
+ test('competency diagnostics and VP completions become facts without fake knowledge nodes', () => {
22
+ const schedule = { days: { [date]: { actual: [
23
+ { node: '', competency: 'contest-reading', kind: 'diagnostic', actualMin: 15, solved: true, independent: true, problemId: 'probe' },
24
+ { node: '', competency: 'contest-decision', kind: 'vp', actualMin: 300, solved: false, independent: true, problemId: 'probe' },
25
+ ] } } }
26
+ const rows = collectEvidence({}, schedule)
27
+ assert.equal(rows.length, 2)
28
+ assert.equal(rows[0].node, '')
29
+ assert.equal(rows[0].competency, 'contest-reading')
30
+ assert.equal(rows[0].minutes, 15)
31
+ assert.equal(rows[0].signal, 'baseline')
32
+ assert.equal(rows[1].competency, 'contest-decision')
33
+ assert.equal(rows[1].signal, 'gap')
34
+ assert.notEqual(rows[0].eventId, rows[1].eventId)
35
+ assert.deepEqual(collectEvidence({}, { days: { [date]: { actual: [{ ...schedule.days[date].actual[0], independent: 'yes' }] } } }), [])
36
+ assert.deepEqual(collectEvidence({ pendingActions: { 'competency:contest-reading': { kind: 'diagnostic', minutes: 15 } } }), [])
37
+ })
20
38
  const reverseKeys = (value) => Array.isArray(value) ? value.map(reverseKeys)
21
39
  : value && typeof value === 'object'
22
40
  ? Object.fromEntries(Object.entries(value).reverse().map(([key, item]) => [key, reverseKeys(item)]))