acmer-coach 1.2.1 → 2.1.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
- 21 个工具,两条轴:
23
+ 26 个工具,三条轴:知识图、比赛能力、训练证据。
24
24
 
25
25
  | 轴 | 含义 |
26
26
  |---|---|
@@ -45,14 +45,37 @@ coach_log 过了 → 记一条,然后夸
45
45
  coach_pool → coach_test → coach_grade 章节检测,过了才标「已验证」
46
46
  ↓
47
47
  coach_schedule → coach_plan 排课
48
+ ↓
49
+ coach_target → coach_vp_import → coach_postmortem → coach_focus
50
+ ↓ 比赛目标、VP 复盘、当前重点
51
+ coach_scope 适用范围、延伸、反例、退出条件
48
52
  ```
49
53
 
50
54
  `coach_wiki` 是知识来源:讲任何知识点之前先查本地 OI Wiki,**别凭记忆讲**。
51
55
 
56
+ ### 目标驱动训练
57
+
58
+ 知识图只回答“有哪些知识点”,不回答“为什么现在学它”。先用
59
+ `coach_target` 设置比赛、日期、目标结果、团队模式、可用时间和能力优先级,
60
+ 再用 `coach_vp_import` 导入 VP,`coach_postmortem` 写复盘,`coach_focus` 计算当前
61
+ 训练重点。每次布置都要能说清楚它服务哪个比赛能力、适用范围、迁移题和退出条件。
62
+
63
+ VP 记录格式是 JSON 数组,每场至少包含 `eventId`、`contest`、`date` 和 `problems`:
64
+
65
+ ```json
66
+ [{"eventId":"vp-西安-01","contest":"区域赛 VP","date":"2026-10-10",
67
+ "durationMinutes":300,"teamMode":"team","problems":[
68
+ {"problemId":"A","status":"AC","readMinutes":8,"solveMinutes":22,
69
+ "attempts":1,"competencies":["contest-reading"]}]}]
70
+ ```
71
+
72
+ `coach_scope` 会返回一个能力或知识点的适用范围、延伸、反例、常见错误、迁移题
73
+ 和退出条件。没有人工范围卡片时它会明确拒绝编造,避免把“讲过”误当成“会用”。
74
+
52
75
  ### 长期学习路线
53
76
 
54
77
  `coach_curriculum assess` 从已有检测、代码判因、做题记录和日程实际完成中列出
55
- 可引用的证据;不会把 rating、学习状态或取消的卷子当成短板。每条记录带内容引用
78
+ 可引用的证据;VP 题目与复盘里的 `competencies` 也会生成稳定证据 id,不会把 rating、学习状态或取消的卷子当成短板。每条记录带内容引用
56
79
  `id` 和事件身份 `eventId`。训练阶段的每个节点必须有真实记录:`finding=gap`
57
80
  引用失败/超时/依赖提示等短板,`finding=extension` 引用已独立做出或过卷的能力起点。
58
81
 
@@ -63,21 +86,21 @@ coach_schedule → coach_plan 排课
63
86
  learned/verified 记录,或路线中在此前安排的必验训练,不能从游标位置推定已经学会。
64
87
 
65
88
  `CURRICULUM.yaml` 保存路线与修订历史:`read` 查看,`create` 建立,`revise` 调整,
66
- `advance` 推进。进入阶段时保存证据事件快照;诊断阶段每个节点需有新的真实观察,
67
- 训练阶段所有节点必须 verified,且各有新的独立做出或过卷记录。改旧备注、成绩或
89
+ `advance` 推进。阶段可以只绑定比赛能力(读题、决策、团队沟通),这类阶段用 VP/复盘证据验收,不必伪装成地图节点。进入阶段时保存证据事件快照;诊断阶段每个节点需有新的真实观察,训练阶段所有节点必须 verified,能力目标必须有阶段后的新 baseline 证据。改旧备注、成绩或
68
90
  把同次做题从课表迁入进度,不算新训练。当前诊断也不能通过改种类或删节点逃过验收。
69
91
  写操作带 `expectedRevision` 和理由,避免旧上下文覆盖新计划。
70
92
  路线按剩余 `estimatedHours`、每周预算和目标日期提示容量不足或估算缺失;这只是
71
93
  调整范围的提示,不会把周平均当成精确日程,也不阻止执行。
72
94
 
73
- 每轮注入长期目标、当前阶段和未完成单元。`coach_assign` 带课程版本,普通训练
95
+ 每轮注入比赛目标、能力重点、长期阶段和未完成单元。`coach_assign` 带课程版本,普通训练
74
96
  限于当前主线;补漏/复习需声明目的和返回依据。完成记录仍归原进度文件,
75
97
  不会因一题 AC 或日历到期自动跳阶段。没有建立课程的用户保持原有工具行为。
76
98
 
77
99
  本周主线可设置 `focusUntil`,到期先复盘修订。路线不提前占用数月日程,
78
100
  `coach_plan` 继续管理近期时间块。旧 v1 路线可读,会标记 `needsReview`;需明确
79
101
  修订当前及后续阶段后迁移 v2,插件不会自动重写。优先级与教学方案仍由模型判断,
80
- 程序只检查证据、前置和验收边界。本功能没有新增 VP 自动读取或后台周报。
102
+ 程序现在会要求目标和 VP 证据先进入策略层;能力卡片仍由仓库维护,未知卡片不会自动臆造。
103
+ 这版仍不包含后台定时同步和自动读取第三方 VP 网站,导出后用 `coach_vp_import` 导入。
81
104
  详见 [长期课程机制](docs/长期课程机制.md)。
82
105
 
83
106
  **「学过」和「已验证」是两级,绝不合并。** 自评 ≠ 掌握 ——
@@ -0,0 +1,92 @@
1
+ version: 1
2
+ competencies:
3
+ - id: contest-reading
4
+ title: 比赛读题与筛选
5
+ contestUse: 在前 30 分钟扫完题面,识别可做题和高风险题,避免漏读与过早开写。
6
+ nodes: []
7
+ appliesTo: [所有区域赛场次]
8
+ extensions: [首轮扫描, 题目分层, 读题时间盒]
9
+ notFor: [把 A 题默认当成签到题, 读一题写一题]
10
+ commonFailures: [漏读题面, 被样例带偏, 没有放弃条件]
11
+ transferProblems: [一场完整 VP]
12
+ exitEvidence: [两场 VP 都记录首轮扫描顺序, 没有漏读题]
13
+ - id: contest-decision
14
+ title: 比赛决策与终局
15
+ contestUse: 根据成功概率、剩余时间和罚时决定继续、换题或集中火力。
16
+ nodes: []
17
+ appliesTo: [区域赛 VP, 最后 60 分钟]
18
+ extensions: [换题阈值, 终局集中, 代码验算分工]
19
+ notFor: [多道难题同时无边界死磕]
20
+ commonFailures: [沉没成本, 最后阶段分散火力, 不复盘换题原因]
21
+ transferProblems: [5 小时团队 VP]
22
+ exitEvidence: [两场 VP 有明确换题记录和终局策略]
23
+ - id: implementation-stability
24
+ title: 实现稳定性
25
+ contestUse: 在限时下把已知套路写成一次通过、边界完整的代码。
26
+ nodes: [数据结构, 二分, 前缀和, 差分, 动态规划基础]
27
+ appliesTo: [中档签到题, 需要快速转化的常见模型]
28
+ extensions: [边界清单, 小样例自测, 重写压缩]
29
+ notFor: [只背模板不理解状态来源]
30
+ commonFailures: [下标错误, 初始化遗漏, 重复提交同一类低级错误]
31
+ transferProblems: [两道未见过的同类题]
32
+ exitEvidence: [两道迁移题限时独立通过, 至少一题隔天复测]
33
+ - id: dp-modeling
34
+ title: DP 建模与迁移
35
+ contestUse: 从题面抽出状态、转移、顺序和复杂度,而不是只认题型名称。
36
+ nodes: [动态规划基础, 区间 DP, 背包 DP, 树形 DP, 状压 DP, 数位 DP]
37
+ appliesTo: [子结构可复用, 状态规模可控, 需要枚举决策顺序]
38
+ extensions: [区间状态, 集合状态, 树上状态, 优化转移]
39
+ notFor: [状态包含不可压缩的全局历史]
40
+ commonFailures: [状态定义含糊, 转移漏情况, 复杂度算错]
41
+ transferProblems: [一道结构相同但题面不同的 DP, 一道隔天迁移题]
42
+ exitEvidence: [能解释状态为何足够, 两道新题独立完成]
43
+ - id: graph-modeling
44
+ title: 图论建模
45
+ contestUse: 识别图结构并选对最短路、连通性、拓扑序、树或网络流模型。
46
+ nodes: [图论基础, 最短路, 拓扑排序, DFS(搜索), 并查集]
47
+ appliesTo: [关系传播, 依赖顺序, 连通块, 路径代价]
48
+ extensions: [DAG DP, 树上问题, 生成树, 流与匹配]
49
+ notFor: [把所有关系题都硬套最短路]
50
+ commonFailures: [建边方向错, 忘记不连通, 复杂度超限]
51
+ transferProblems: [两种表面不同的关系建模题]
52
+ exitEvidence: [限时识别模型并独立写出两题]
53
+ - id: data-structures
54
+ title: 数据结构选型与实现
55
+ contestUse: 根据操作、约束和查询方式选择合适结构并稳定实现。
56
+ nodes: [树状数组, 线段树, 单调队列, 单调栈, 树链剖分]
57
+ appliesTo: [动态维护, 区间查询, 单调性优化]
58
+ extensions: [懒标记, 可持久化, 合并技巧, 复杂度降阶]
59
+ notFor: [数据结构复杂度超过题目实际需求]
60
+ commonFailures: [区间边界错, 懒标记下传错, 结构选重]
61
+ transferProblems: [同结构不同操作题]
62
+ exitEvidence: [模板能从零写出, 新题能说明为何选它]
63
+ - id: math-combinatorics
64
+ title: 数学与组合建模
65
+ contestUse: 把计数、模运算、概率和构造题压成可计算的结构。
66
+ nodes: [排列组合, 数论基础, 概率论, 博弈论]
67
+ appliesTo: [计数, 期望, 模意义运算, 不变量]
68
+ extensions: [容斥, 生成函数, 矩阵快速幂, 组合恒等式]
69
+ notFor: [只凭样例猜规律]
70
+ commonFailures: [重复计数, 模逆不存在, 边界概率遗漏]
71
+ transferProblems: [两道不同叙事的计数题]
72
+ exitEvidence: [能写出计数对象和去重方式, 一题隔天复测]
73
+ - id: code-review
74
+ title: 代码检查与调试
75
+ contestUse: 在提交前快速发现边界、溢出、复杂度和实现偏差。
76
+ nodes: [枚举, 递归 & 分治, 动态规划基础]
77
+ appliesTo: [所有需要限时提交的题]
78
+ extensions: [反例构造, 对拍思维, 小样例清单]
79
+ notFor: [盲目重写整份代码]
80
+ commonFailures: [没有最小反例, 只看样例, 不验证复杂度]
81
+ transferProblems: [给出一份错误代码并定位最小反例]
82
+ exitEvidence: [三次提交前检查都能指出具体风险]
83
+ - id: team-communication
84
+ title: 团队沟通与分工
85
+ contestUse: 让三个人并行推进、及时同步、减少重复劳动和信息丢失。
86
+ nodes: []
87
+ appliesTo: [团队 VP, 区域赛现场]
88
+ extensions: [口头题解格式, 代码交接, 验算角色, 决策记录]
89
+ notFor: [三个人长期各做各的]
90
+ commonFailures: [没人汇报, 两人重复做题, 交接时丢约束]
91
+ transferProblems: [三人 5 小时 VP]
92
+ exitEvidence: [两场 VP 有分工记录和交接记录]
@@ -7,6 +7,20 @@
7
7
 
8
8
  ## 教练职责(核心)
9
9
 
10
+ ### 比赛目标先于知识图
11
+
12
+ * 有比赛目标时,先调 `coach_target read`,再调 `coach_focus`。没有目标时不能用
13
+ rating、游标或地图后继猜长期方向;先建立比赛、日期、结果目标、团队模式、时间预算
14
+ 和能力优先级。
15
+ * `coach_focus` 给的是比赛能力重点,不是知识点清单。每次训练必须说明它服务哪个能力、
16
+ 为什么现在排、暂缓什么,以及用什么表现退出。知识图只负责把能力重点展开成可学节点。
17
+ * 有 VP 时先导入 `coach_vp_import`,赛后调 `coach_postmortem`。VP 的读题、耗时、提交、
18
+ 换题和团队问题优先于 rating;没有 VP 证据时要明确说“尚无记录”,不能装作知道短板。
19
+ * 讲知识点之前调 `coach_scope`。必须说明适用范围、延伸、反例、常见错误、迁移题和退出
20
+ 条件;工具返回“没有范围卡片”时不许凭记忆补一段看似完整的范围。
21
+ * 训练重点是比赛能力,不是沿着地图无限往下走。阶段达到目标后可以停在当前节点,转做
22
+ 迁移题、限时题、VP 或复盘;后继节点只有在它服务当前能力目标时才进入。
23
+
10
24
  ### 长期课程先于每日选点
11
25
 
12
26
  * 长期训练先用 `coach_curriculum read` 查看路线,再用 `assess` 查实际编排依据。
@@ -16,7 +30,7 @@
16
30
  必须填写 `basis`、`goalContribution`、`priorityReason`、`estimatedHours`、
17
31
  `evidenceRefs`、`finding`、`kind`、`uncertainty`、`diagnosticMethod`。
18
32
  说清观察到了什么、它怎样服务目标、为何排在这里、预计投入多少,不套统一算法清单。
19
- * `kind=training` 的每个节点都要对应真实证据:补短板用 `finding=gap`,引用 gap;
33
+ * `kind=training` 的每个节点都要对应真实证据:补短板用 `finding=gap`,引用 gap;读题、决策、团队沟通这类没有单一地图节点的阶段改用 `competencies`,引用 VP/复盘产生的能力证据;
20
34
  在已证实能力上进阶用 `finding=extension`,引用 baseline。记录指向什么由你解读,
21
35
  但**不能写一段理由替代不存在的记录**,也不能把别的节点的失败贴来凑依据。
22
36
  * 缺依据先安排 `kind=diagnostic/finding=unknown`,写尚待确认的问题和具体取证方法。
@@ -51,7 +65,8 @@
51
65
 
52
66
  下面九条是硬规则,每条都能机械检查。违反就是没做好本职工作:
53
67
 
54
- 1. **给训练指令前,必须先调 `coach_next`**。不许凭印象挑知识点 ——
68
+ 1. **给训练指令前,必须先调 `coach_target`/`coach_focus`,再调 `coach_next`**。
69
+ 不许只凭 rating 或地图后继挑知识点 ——
55
70
  它可能在地图里根本不存在,或者前置还没满足。
56
71
  2. **每条指令必须带地图节点 id**。节点 id 只能从地图取到,
57
72
  所以这条会自动逼你先读地图。
@@ -336,6 +336,26 @@ test('broken course is surfaced and never treated as an empty plan', () => {
336
336
  assert.equal(readFileSync(f.store.path, 'utf8'), 'revision: broken\n')
337
337
  })
338
338
 
339
+ test('competency-only phases can use fresh VP evidence as their exit gate', () => {
340
+ const f = fixture()
341
+ const evidence = [{ id: 'vp:old:contest-reading', eventId: 'old', source: 'vp', node: '', competency: 'contest-reading', date: '2026-10-08', signal: 'gap', summary: 'VP 读题漏读', fingerprint: 'vp:old:contest-reading' }]
342
+ f.options.loadStrategyEvidence = () => evidence
343
+ const store = createCurriculumStore(f.options)
344
+ const phase = {
345
+ id: 'reading', title: '比赛读题与筛选', outcome: '在前30分钟完成扫描并记录取舍', competencies: ['contest-reading'], nodes: [],
346
+ exitCriteria: '两场新 VP 都有扫描顺序、取舍理由和漏读检查', requiredVerified: [], kind: 'training', finding: 'gap',
347
+ basis: 'VP 已记录读题缺口,需要在真实比赛节奏中修复', goalContribution: '提高区域赛前半小时的可做题识别和取舍质量',
348
+ priorityReason: '它直接决定队伍是否把时间用在能拿分的题上', estimatedHours: 6, uncertainty: '', diagnosticMethod: '',
349
+ evidenceRefs: ['vp:old:contest-reading'],
350
+ }
351
+ assert.equal(store.execute({ action: 'create', expectedRevision: 0, goal: '区域赛稳定表现', baseline: '已有一场 VP 的读题缺口记录', weeklyHours: 30, phases: [phase], reason: '根据 VP 的读题记录建立能力课程' }).ok, true)
352
+ assert.deepEqual(store.snapshot().phaseCompetencies, ['contest-reading'])
353
+ assert.deepEqual(store.snapshot().missingFreshEvidence, ['contest-reading'])
354
+ evidence.push({ id: 'vp:new:contest-reading', eventId: 'new', source: 'vp', node: '', competency: 'contest-reading', date: '2026-10-09', signal: 'baseline', summary: '新 VP 读题记录', fingerprint: 'vp:new:contest-reading' })
355
+ assert.deepEqual(store.snapshot().missingFreshEvidence, [])
356
+ assert.equal(store.execute({ action: 'advance', expectedRevision: 1, reason: '新 VP 已记录扫描顺序和取舍理由,满足本阶段能力验收' }).status, 'complete')
357
+ })
358
+
339
359
  test('course tool output matches its declared schema on success, rejection and absence', () => {
340
360
  const f = fixture(); const tool = curriculumTool(f.store)
341
361
  for (const result of [f.store.execute({ action: 'read' }), f.create(), f.store.execute({ action: 'advance', expectedRevision: 1, reason: '检查未验收阶段是否能够被强行推进' })]) validateShape(tool.output.schema, result)
package/dev/run-all.sh CHANGED
@@ -8,6 +8,7 @@
8
8
  # setup-check 冷启动(离线)
9
9
  # curriculum-check 长期课程、版本、阶段验收与任务关联
10
10
  # curriculum-evidence 编排证据目录、引用与事件身份
11
+ # strategy-check 比赛目标、VP 证据、复盘与重点选择
11
12
  # parity-check 和线上那份的保真度对比
12
13
  #
13
14
  # 主验收跑的是**已安装副本**(不是源码)—— 那才是运行时真正加载的东西。
@@ -63,6 +64,7 @@ run "wiki-check" node dev/wiki-check.mjs
63
64
  run "setup-check" node dev/setup-check.mjs
64
65
  run "curriculum" node --test dev/curriculum-check.mjs
65
66
  run "evidence" node --test dev/curriculum-evidence-check.mjs
67
+ run "strategy" node --test dev/strategy-check.mjs
66
68
  if [ "${COACH_LIVE_INDEX+x}" = x ] || node -e "
67
69
  const fs=require('fs'), os=require('os'), path=require('path');
68
70
  process.exit(fs.existsSync(path.join(os.homedir(), '.dsh', 'profiles', 'web', 'node_modules', 'dsh-coach', 'index.js')) ? 0 : 1);
@@ -0,0 +1,158 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { mkdtempSync, rmSync, existsSync, writeFileSync } from 'node:fs'
4
+ import { join } from 'node:path'
5
+ import { tmpdir } from 'node:os'
6
+ import { createStrategyStore } from '../lib/strategy.js'
7
+
8
+ function fixture() {
9
+ const dir = mkdtempSync(join(tmpdir(), 'acmer-coach-strategy-'))
10
+ const store = createStrategyStore({
11
+ dataDir: dir,
12
+ competenciesPath: join(process.cwd(), 'assets', 'knowledge', 'COMPETENCIES.yaml'),
13
+ })
14
+ return { dir, store }
15
+ }
16
+
17
+ test('target is explicit and revision protected', () => {
18
+ const { dir, store } = fixture()
19
+ try {
20
+ assert.equal(store.target('read').revision, 0)
21
+ const saved = store.target('set', {
22
+ expectedRevision: 0, contest: '西安区域赛', date: '2026-10-18', result: '区域赛金牌',
23
+ teamMode: 'team', weeklyHours: 35,
24
+ constraints: { weekday: 5, saturday: 10, sunday: 0 },
25
+ priorities: [{ id: 'contest-reading', weight: 100, reason: '区域赛前要先稳定读题' }],
26
+ })
27
+ assert.equal(saved.ok, true)
28
+ assert.equal(saved.revision, 1)
29
+ assert.equal(store.target('set', { expectedRevision: 0 }).ok, false)
30
+ assert.equal(existsSync(join(dir, 'TARGET.yaml')), true)
31
+ } finally { rmSync(dir, { recursive: true, force: true }) }
32
+ })
33
+
34
+ test('VP import is idempotent and postmortem drives focus', () => {
35
+ const { dir, store } = fixture()
36
+ try {
37
+ store.target('set', {
38
+ expectedRevision: 0, contest: '西安区域赛', date: '2026-10-18', result: '区域赛金牌',
39
+ teamMode: 'team', weeklyHours: 35,
40
+ priorities: [
41
+ { id: 'contest-reading', weight: 80 },
42
+ { id: 'dp-modeling', weight: 70 },
43
+ ],
44
+ })
45
+ const event = { eventId: 'vp-1', contest: '训练 VP', date: '2026-10-10', durationMinutes: 300,
46
+ problems: [{ problemId: 'A', status: 'AC', solveMinutes: 20, competencies: ['contest-reading'] }] }
47
+ assert.equal(store.vpImport([event]).total, 1)
48
+ assert.equal(store.vpImport([event]).total, 1)
49
+ assert.equal(store.postmortem({ eventId: 'vp-1', competencies: ['contest-reading'], rootCauses: ['扫题不完整'], nextActions: ['固定首轮扫描'] }).ok, true)
50
+ const focus = store.focus()
51
+ assert.equal(focus.ok, true)
52
+ assert.equal(focus.focus[0].id, 'contest-reading')
53
+ } finally { rmSync(dir, { recursive: true, force: true }) }
54
+ })
55
+
56
+ test('scope refuses unknown knowledge instead of inventing applicability', () => {
57
+ const { dir, store } = fixture()
58
+ try {
59
+ const v = store.scope('不存在的知识点')
60
+ assert.equal(v.ok, false)
61
+ assert.equal(v.found, false)
62
+ } finally { rmSync(dir, { recursive: true, force: true }) }
63
+ })
64
+
65
+ test('first-use focus has a complete refusal shape and targets reject unknown cards', () => {
66
+ const { dir, store } = fixture()
67
+ try {
68
+ const empty = store.focus()
69
+ assert.deepEqual(Object.keys(empty).sort(), ['contest', 'deadline', 'evidenceEvents', 'focus', 'ok', 'postmortems', 'postponed', 'reject'])
70
+ const rejected = store.target('set', {
71
+ expectedRevision: 0, contest: '西安区域赛', date: '2026-10-18', result: '区域赛金牌',
72
+ weeklyHours: 35, priorities: [{ id: 'invented-card', weight: 100 }],
73
+ })
74
+ assert.equal(rejected.ok, false)
75
+ } finally { rmSync(dir, { recursive: true, force: true }) }
76
+ })
77
+
78
+ test('target route rejects an unrelated progress node but allows a course-bound route', () => {
79
+ const { dir, store } = fixture()
80
+ try {
81
+ store.target('set', {
82
+ expectedRevision: 0, contest: '西安区域赛', date: '2026-10-18', result: '区域赛金牌',
83
+ weeklyHours: 35, priorities: [{ id: 'dp-modeling', weight: 100 }],
84
+ })
85
+ const unrelated = store.route('树状数组', { purpose: 'progress', courseBound: false })
86
+ assert.equal(unrelated.ok, false)
87
+ assert.match(unrelated.reject, /dp-modeling|当前重点/)
88
+ const courseBound = store.route('树状数组', { purpose: 'progress', courseBound: true })
89
+ assert.equal(courseBound.ok, true)
90
+ assert.equal(courseBound.courseBound, true)
91
+ } finally { rmSync(dir, { recursive: true, force: true }) }
92
+ })
93
+
94
+ test('VP problem competencies count as evidence before a postmortem exists', () => {
95
+ const { dir, store } = fixture()
96
+ try {
97
+ store.target('set', {
98
+ expectedRevision: 0, contest: '西安区域赛', date: '2026-10-18', result: '区域赛金牌',
99
+ weeklyHours: 35, priorities: [
100
+ { id: 'dp-modeling', weight: 80 },
101
+ { id: 'contest-reading', weight: 70 },
102
+ ],
103
+ })
104
+ store.vpImport([{ eventId: 'vp-2', contest: '训练 VP', date: '2026-10-10', durationMinutes: 300,
105
+ problems: [{ problemId: 'B', status: 'WA', competencies: ['dp-modeling'] }] }])
106
+ const focus = store.focus()
107
+ assert.equal(focus.focus[0].id, 'dp-modeling')
108
+ assert.equal(focus.focus[0].evidenceCount, 1)
109
+ } finally { rmSync(dir, { recursive: true, force: true }) }
110
+ })
111
+
112
+ test('strategy exposes stable competency evidence for curriculum gates', () => {
113
+ const { dir, store } = fixture()
114
+ try {
115
+ store.vpImport([{ eventId: 'vp-evidence', contest: '训练 VP', date: '2026-10-10', durationMinutes: 300,
116
+ problems: [{ problemId: 'C', status: 'WA', competencies: ['contest-decision'] }] }])
117
+ const result = store.evidence()
118
+ assert.equal(result.ok, true)
119
+ assert.equal(result.evidence[0].id, 'vp:vp-evidence:C:contest-decision')
120
+ assert.equal(result.evidence[0].signal, 'gap')
121
+ assert.equal(result.evidence[0].competency, 'contest-decision')
122
+ } finally { rmSync(dir, { recursive: true, force: true }) }
123
+ })
124
+
125
+ test('invalid VP input is rejected without bumping revision', () => {
126
+ const { dir, store } = fixture()
127
+ try {
128
+ const result = store.vpImport([{ eventId: '', contest: '坏记录', date: '2026-10-10', problems: [] }])
129
+ assert.equal(result.ok, false)
130
+ assert.equal(result.revision, 0)
131
+ assert.equal(result.imported, 0)
132
+ assert.equal(result.invalid.length, 1)
133
+ assert.equal(existsSync(join(dir, 'VP_EVENTS.yaml')), false)
134
+ } finally { rmSync(dir, { recursive: true, force: true }) }
135
+ })
136
+
137
+ test('invalid target priorities are rejected instead of silently dropped', () => {
138
+ const { dir, store } = fixture()
139
+ try {
140
+ const result = store.target('set', {
141
+ expectedRevision: 0, contest: '西安区域赛', date: '2026-10-18', result: '区域赛金牌',
142
+ weeklyHours: 35, priorities: [{ id: 'dp-modeling', weight: 'bad' }],
143
+ })
144
+ assert.equal(result.ok, false)
145
+ assert.match(result.reject, /priorit|weight|能力/)
146
+ assert.equal(result.revision, 0)
147
+ } finally { rmSync(dir, { recursive: true, force: true }) }
148
+ })
149
+
150
+ test('corrupt stored strategy data returns a structured refusal', () => {
151
+ const { dir, store } = fixture()
152
+ try {
153
+ writeFileSync(join(dir, 'TARGET.yaml'), 'version: 1\nrevision: 4\npriorities: bad\n', 'utf8')
154
+ const result = store.target('read')
155
+ assert.equal(result.ok, false)
156
+ assert.match(result.reject, /TARGET.yaml/)
157
+ } finally { rmSync(dir, { recursive: true, force: true }) }
158
+ })
package/dev.sh CHANGED
@@ -35,7 +35,7 @@ done
35
35
  # 从副本目录跑 `bash dev.sh` 走的是另一条路(`dsh plugin remove/add`,而那条
36
36
  # 路不可靠),和这份的行为不一样。换句话说,**同一个命令,从哪个目录按回车
37
37
  # 决定跑哪份脚本** —— 两份 dev.sh 就是两份真相,正是这份文件在防的那种坑。
38
- SYNC_FILES='index.js package.json verify.mjs verify-shape.mjs progress-cli.mjs build-node-meta.mjs cordis.patch.yml README.md lib/client.js lib/setup.js lib/curriculum.js lib/curriculum-evidence.js lib/curriculum-planning.js assets/rules/coach-rules.md dev/curriculum-check.mjs dev/curriculum-evidence-check.mjs docs/长期课程机制.md dev.sh'
38
+ SYNC_FILES='index.js package.json verify.mjs verify-shape.mjs progress-cli.mjs build-node-meta.mjs cordis.patch.yml README.md lib/client.js lib/setup.js lib/curriculum.js lib/curriculum-evidence.js lib/curriculum-planning.js lib/strategy.js assets/knowledge/COMPETENCIES.yaml assets/rules/coach-rules.md dev/curriculum-check.mjs dev/curriculum-evidence-check.mjs dev/strategy-check.mjs docs/长期课程机制.md dev.sh'
39
39
  STALE=''
40
40
  for f in $SYNC_FILES; do
41
41
  for d in "${DSTS[@]}"; do
@@ -59,8 +59,11 @@ assess 最多返回最后 200 条,并给出 `evidenceCount/evidenceTruncated`
59
59
 
60
60
  ## 阶段如何编排
61
61
 
62
- 基础字段 `id/title/outcome/nodes/exitCriteria` 分别标识阶段、能力成果、地图节点和
63
- 验收方法;一个阶段 1–50 个不重复的真实节点,整条路线 1–30 个阶段。还必须提供:
62
+ 基础字段 `id/title/outcome/nodes/competencies/exitCriteria` 分别标识阶段、能力成果、地图节点、比赛能力和验收方法;一个阶段至少绑定一个真实地图节点或比赛能力,地图节点最多 50 个,整条路线 1–30 个阶段。读题、决策、团队沟通这类没有单一算法节点的阶段直接填 `competencies`。
63
+
64
+ VP 题目里的 `competencies` 和复盘里的 `competencies` 会生成稳定证据 id(如 `vp:event:problem:competency`),进入课程时被快照;能力阶段只能用进入阶段后的新 baseline 记录通过 `advance`。
65
+
66
+ 还必须提供:
64
67
 
65
68
  | 字段 | 要回答的问题 |
66
69
  |---|---|
@@ -76,7 +79,7 @@ assess 最多返回最后 200 条,并给出 `evidenceCount/evidenceTruncated`
76
79
 
77
80
  三种可编排阶段:
78
81
 
79
- * **training**:每个节点都必须有对应证据。`finding=gap` 必须引用该节点的 gap;
82
+ * **training**:每个节点或比赛能力都必须有对应证据。`finding=gap` 必须引用该节点/能力的 gap;
80
83
  `finding=extension` 必须引用该节点的 baseline。无关节点的失败不能用来凑依据。
81
84
  所有节点都必须列入 `requiredVerified`,不能用空数组逃过检测。
82
85
  * **diagnostic**:`finding=unknown`,明确待确认问题、取证方法与能力目标的关系。
@@ -111,7 +114,7 @@ assess 最多返回最后 200 条,并给出 `evidenceCount/evidenceTruncated`
111
114
  进入阶段时保存 `enteredAt/entryEvidenceRefs/entryEvidenceEvents`。advance 必须同时满足:
112
115
 
113
116
  * diagnostic:每个节点都有进入时未出现、日期不早于进入日的新有效观察。
114
- * training:全部节点 verified,且每个节点有本阶段新的独立做出或检测 passed 事件。
117
+ * training:地图节点全部 verified,且每个节点有本阶段新的独立做出或检测 passed 事件;能力目标全部有本阶段新的 baseline 证据。
115
118
  * 当前阶段没有待完成动作,也没有检测卷等待判定。
116
119
 
117
120
  旧记录改内容或换来源不能替代新事件。诊断阶段不能通过 revise 改成训练或删除待诊断