acmer-coach 1.1.0 → 1.2.1

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
- 20 个工具,两条轴:
23
+ 21 个工具,两条轴:
24
24
 
25
25
  | 轴 | 含义 |
26
26
  |---|---|
@@ -30,6 +30,8 @@
30
30
  核心闭环:
31
31
 
32
32
  ```
33
+ coach_curriculum assess 查真实记录 → 目标与前置排序 → 建有依据的阶段
34
+ ↓ 缺依据先诊断;保存本周主线;产生新证据并验收后推进
33
35
  coach_setup 首次:体检 → 建目录建库 → 灌题池 → 从 CF 同步数据
34
36
  coach_import 洛谷/牛客的记录从这里进(CF 不用,有公开接口)
35
37
  ↓
@@ -47,6 +49,37 @@ coach_schedule → coach_plan 排课
47
49
 
48
50
  `coach_wiki` 是知识来源:讲任何知识点之前先查本地 OI Wiki,**别凭记忆讲**。
49
51
 
52
+ ### 长期学习路线
53
+
54
+ `coach_curriculum assess` 从已有检测、代码判因、做题记录和日程实际完成中列出
55
+ 可引用的证据;不会把 rating、学习状态或取消的卷子当成短板。每条记录带内容引用
56
+ `id` 和事件身份 `eventId`。训练阶段的每个节点必须有真实记录:`finding=gap`
57
+ 引用失败/超时/依赖提示等短板,`finding=extension` 引用已独立做出或过卷的能力起点。
58
+
59
+ 缺记录时先建 `diagnostic` 阶段,写明要确认的问题与取证方法,不能直接宣布弱项。
60
+ 远期设想用 `provisional` 保留,补足依据前不能执行。每个阶段除能力成果和验收条件外,
61
+ 还要写 `basis`、`goalContribution`、`priorityReason`、`estimatedHours`、`evidenceRefs`、
62
+ `finding`、`kind`、`uncertainty`、`diagnosticMethod`。前置必须来自显式的
63
+ learned/verified 记录,或路线中在此前安排的必验训练,不能从游标位置推定已经学会。
64
+
65
+ `CURRICULUM.yaml` 保存路线与修订历史:`read` 查看,`create` 建立,`revise` 调整,
66
+ `advance` 推进。进入阶段时保存证据事件快照;诊断阶段每个节点需有新的真实观察,
67
+ 训练阶段所有节点必须 verified,且各有新的独立做出或过卷记录。改旧备注、成绩或
68
+ 把同次做题从课表迁入进度,不算新训练。当前诊断也不能通过改种类或删节点逃过验收。
69
+ 写操作带 `expectedRevision` 和理由,避免旧上下文覆盖新计划。
70
+ 路线按剩余 `estimatedHours`、每周预算和目标日期提示容量不足或估算缺失;这只是
71
+ 调整范围的提示,不会把周平均当成精确日程,也不阻止执行。
72
+
73
+ 每轮注入长期目标、当前阶段和未完成单元。`coach_assign` 带课程版本,普通训练
74
+ 限于当前主线;补漏/复习需声明目的和返回依据。完成记录仍归原进度文件,
75
+ 不会因一题 AC 或日历到期自动跳阶段。没有建立课程的用户保持原有工具行为。
76
+
77
+ 本周主线可设置 `focusUntil`,到期先复盘修订。路线不提前占用数月日程,
78
+ `coach_plan` 继续管理近期时间块。旧 v1 路线可读,会标记 `needsReview`;需明确
79
+ 修订当前及后续阶段后迁移 v2,插件不会自动重写。优先级与教学方案仍由模型判断,
80
+ 程序只检查证据、前置和验收边界。本功能没有新增 VP 自动读取或后台周报。
81
+ 详见 [长期课程机制](docs/长期课程机制.md)。
82
+
50
83
  **「学过」和「已验证」是两级,绝不合并。** 自评 ≠ 掌握 ——
51
84
  合成一个「会/不会」就等于退回「从数据推断掌握度」,而数据能说「花了很久」,
52
85
  说不出「卡在哪」。所以 `verified` 手标不了,它只能由检测产生。
@@ -153,8 +186,11 @@ dsh 的会话上下文有限 —— 新开一个会话,教练不记得上次
153
186
 
154
187
  | 文件 | 作用 |
155
188
  |---|---|
156
- | `index.js` | 插件本体(host 端,20 个工具 + 规则注入) |
189
+ | `index.js` | 插件本体(host 端,21 个工具 + 规则注入) |
157
190
  | `lib/setup.js` | 环境搭建 + CF 公开数据同步 |
191
+ | `lib/curriculum.js` | 持久课程路线、阶段验收门槛、版本与修订历史 |
192
+ | `lib/curriculum-evidence.js` | 从实际记录派生证据引用与事件身份,去重课表和训练记录 |
193
+ | `lib/curriculum-planning.js` | 阶段编排依据、前置顺序与进入阶段后的新证据检查 |
158
194
  | `lib/client.js` | 浏览器端:会话头部「技能树」按钮 + 弹层 |
159
195
  | `package.json` | 声明 `dsh.bundle.patch` + `dsh.client` |
160
196
  | `cordis.patch.yml` | bundle 层的 insert 行 |
@@ -176,7 +212,7 @@ dsh 的会话上下文有限 —— 新开一个会话,教练不记得上次
176
212
  |---|---|---|
177
213
  | **规则** | `assets/rules/coach-rules.md` | 插件用 `systemPrompt.section()` 注进 system prompt(和人设同一条路)。**改它要重启 dsh** |
178
214
  | **知识** | 地图 4 份 + OI Wiki 检索库 | `coach_setup init` 铺到数据目录;OI Wiki 直接从包里读 |
179
- | **工具** | 19 个 | 装完就在 |
215
+ | **工具** | 21 个 | 装完就在 |
180
216
 
181
217
  规则进的是 **system prompt,不是消息流**:行为约束每轮都得在场,
182
218
  而消息流会被长对话把尾巴挤掉。文本是**静态**的(只在加载时读一次)——
@@ -191,13 +227,13 @@ dsh 的会话上下文有限 —— 新开一个会话,教练不记得上次
191
227
  | `NODE_ENTRY.yaml` | 每个节点的入门段位(题池 p25),机器算 |
192
228
  | `NODE_META.yaml` | `tier` 标注(core / normal / rare / skip),人维护 |
193
229
 
194
- `PROGRESS.yaml` 和 `SCHEDULE.yaml` **不随包发** —— 它们是你的进度和日程,
230
+ `PROGRESS.yaml`、`SCHEDULE.yaml` 和 `CURRICULUM.yaml` **不随包发** —— 它们是你的进度、日程和课程,
195
231
  首次写入时由插件自己生成(带正确的文件头)。发模板等于给文件头造第二份真相。
196
232
 
197
233
  ## 开发回路
198
234
 
199
235
  ```bash
200
- bash dev.sh # 同步副本 + 验收 + 形状扫描 + 比账本指纹
236
+ bash dev.sh # 同步副本 + 验收 + 形状扫描 + 课程/证据测试 + 比账本指纹
201
237
  ```
202
238
 
203
239
  单独跑:
@@ -207,6 +243,8 @@ node verify.mjs && node verify-shape.mjs # 主验收
207
243
  node dev/w1-check.mjs # 解绑 + 闸门 + 规则注入
208
244
  node dev/wiki-check.mjs # OI Wiki 检索
209
245
  node dev/setup-check.mjs # 冷启动(离线)
246
+ node --test dev/curriculum-check.mjs # 长期课程(隔离数据)
247
+ node --test dev/curriculum-evidence-check.mjs # 证据目录、事件身份与重复记录
210
248
  COACH_TEST_NET=1 node dev/setup-check.mjs <handle> # 冷启动(真打 CF)
211
249
  bash dev/trace-scan.sh # 开源前反证:没留私人痕迹
212
250
  ```
@@ -7,6 +7,45 @@
7
7
 
8
8
  ## 教练职责(核心)
9
9
 
10
+ ### 长期课程先于每日选点
11
+
12
+ * 长期训练先用 `coach_curriculum read` 查看路线,再用 `assess` 查实际编排依据。
13
+ `assess` 从 checks、代码判因、passes 和课表实际完成中给出 `id/eventId`;记录多时
14
+ 按 `evidenceNodes` 缩小范围。**rating、节点状态、取消的卷子不能代替短板证据。**
15
+ * 建立或修订路线前核实目标、起点与可用时间。每个阶段除能力成果、节点和验收条件外,
16
+ 必须填写 `basis`、`goalContribution`、`priorityReason`、`estimatedHours`、
17
+ `evidenceRefs`、`finding`、`kind`、`uncertainty`、`diagnosticMethod`。
18
+ 说清观察到了什么、它怎样服务目标、为何排在这里、预计投入多少,不套统一算法清单。
19
+ * `kind=training` 的每个节点都要对应真实证据:补短板用 `finding=gap`,引用 gap;
20
+ 在已证实能力上进阶用 `finding=extension`,引用 baseline。记录指向什么由你解读,
21
+ 但**不能写一段理由替代不存在的记录**,也不能把别的节点的失败贴来凑依据。
22
+ * 缺依据先安排 `kind=diagnostic/finding=unknown`,写尚待确认的问题和具体取证方法。
23
+ 诊断需用新检测、代码判因或实际做题记录回答问题,再据结果修改后续路线。
24
+ 远期假设用 `kind=provisional/finding=unknown` 保存;待定阶段不能执行或直接进入。
25
+ * 阶段顺序须满足地图前置。只认可显式 learned/verified,以及此前必验训练的节点;
26
+ 同阶段节点也按前置顺序列出。**cursor 的祖先闭包不是已学会的证明。** 布置实际
27
+ 训练时前置仍必须已经有显式记录,不能拿未来阶段的承诺当今天的掌握。
28
+ * 近期细、远期粗;本周主线只从当前阶段取,用 `focusNodes` 和 `focusUntil` 保存。
29
+ * 每次布置前沿用当前阶段和未完成单元,再查地图/日程。`coach_assign` 带
30
+ `curriculumRevision`,`why` 解释这个动作如何服务于阶段成果;不要每轮重新挑方向。
31
+ * 补前置、修失败或到期复习可以暂离主线,用 `purpose` 与 `routeReason` 说明
32
+ 关系和回归条件。当前单元没结束时先完成或明确撤销,别另开一个。
33
+ * 当用户临时问一个算法,正常解释;**问答本身不改变课程路线**。
34
+ * 每周或本周主线到期时,对照计划与真实完成/检测记录复盘;调整走 `revise`,
35
+ 保留理由和版本。不能因为日程变化就悄悄覆盖长期目标。
36
+ * 进入阶段时插件保存已有证据事件快照。诊断验收要求每个节点有新观察;训练验收
37
+ 要求所有节点 verified,并且每个节点有本阶段新的独立做出或过卷事件。
38
+ 改旧备注、修改同条成绩、同次做题从 SCHEDULE 迁入 PROGRESS 都不算新事件。
39
+ **当前诊断不能改成训练或删掉待诊断节点来绕过验收。**
40
+ * 阶段推进由你结合能力验收判断,再调 `advance`,写清来源。缺新证据、必验节点不齐、
41
+ 动作或检测卷未结束时工具会拒绝。机械门槛通过也不等于能力自动达标;
42
+ **刷够题数、讲完内容、到了周末都不能替代能力验收。** 优先级理由仍需教学判断,
43
+ 程序没有证明这条路线教育上最优。
44
+ * 旧 v1 路线可查看,但 `needsReview` 时不能继续执行;先 assess,再 revise 补齐当前
45
+ 及后续阶段的依据,明确迁移 v2。已完成阶段保留,不能自动编造依据或改写旧历史。
46
+ * `coach_plan` 仍只排近期时间块,`coach_curriculum` 保存课程顺序与阶段目标,
47
+ `PROGRESS.yaml` 保存实际学习/检测记录,不复制另一套掌握状态。
48
+
10
49
  用户要当**学生**,不要当老师。他排不了优先级 —— 那恰好是初学者没有的知识
11
50
  (循环依赖:用你还不会的东西决定你该怎么学)。所以**排优先级是教练的活**。
12
51
 
@@ -0,0 +1,403 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { mkdtempSync, writeFileSync, readFileSync } from 'node:fs'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+ import { parse, stringify } from 'yaml'
7
+ import { createCurriculumStore, curriculumTool } from '../lib/curriculum.js'
8
+ import { collectEvidence } from '../lib/curriculum-evidence.js'
9
+
10
+ const byId = new Map([
11
+ ['A', { name: 'A', depends: [] }], ['B', { name: 'B', depends: ['A'] }],
12
+ ['C', { name: 'C', depends: ['A'] }], ['D', { name: 'D', depends: ['B'] }],
13
+ ['E', { name: 'E', depends: ['D'] }],
14
+ ])
15
+ const testBase = process.env.COACH_TEST_TMP || tmpdir()
16
+ const seedNodes = {
17
+ A: { status: 'learned' },
18
+ B: { checks: [{ date: '2026-10-08', outcome: 'failed' }] },
19
+ C: { checks: [{ date: '2026-10-08', outcome: 'failed' }] },
20
+ D: { checks: [{ date: '2026-10-08', outcome: 'failed' }] },
21
+ }
22
+ const seedEvidence = collectEvidence({ nodes: seedNodes })
23
+ const phases = [
24
+ { id: 'foundation', title: '稳定基础能力', outcome: '独立建模与实现', nodes: ['B', 'C'], exitCriteria: '独立变式与延迟复测', requiredVerified: ['B', 'C'] },
25
+ { id: 'transfer', title: '组合迁移', outcome: '识别组合题机制', nodes: ['D'], exitCriteria: '无标签混合题独立完成', requiredVerified: ['D'] },
26
+ ].map((phase) => ({ ...phase, kind: 'training', finding: 'gap',
27
+ basis: '已存在对应知识点的未通过检测记录,需要针对性训练',
28
+ goalContribution: '通过独立建模和稳定实现提升区域赛可完成题数',
29
+ priorityReason: '先修复已观测的基础问题,再进入有依赖的组合训练',
30
+ estimatedHours: 8, uncertainty: '', diagnosticMethod: '',
31
+ evidenceRefs: seedEvidence.filter((entry) => phase.nodes.includes(entry.node)).map((entry) => entry.id),
32
+ }))
33
+ function prove(progress, node) {
34
+ const entry = progress.nodes[node] ??= {}
35
+ entry.status = 'verified'
36
+ ;(entry.checks ??= []).push({ date: '2026-10-09', outcome: 'passed' })
37
+ }
38
+ function fixture() {
39
+ const dataDir = mkdtempSync(join(testBase, 'coach-curriculum-'))
40
+ const progress = { nodes: structuredClone(seedNodes), pendingActions: {} }
41
+ const options = { dataDir, loadMap: () => ({ byId }), loadProgress: () => progress,
42
+ now: () => new Date('2026-10-09T12:00:00+08:00') }
43
+ const store = createCurriculumStore(options)
44
+ const create = (extra = {}) => store.execute({ action: 'create', expectedRevision: 0,
45
+ goal: '区域赛稳定表现,长期向 WF 发展', baseline: '水平与队伍缺口已核实,尚需独立检测',
46
+ weeklyHours: 30, phases, reason: '根据用户目标与真实训练表现建立课程路线', ...extra })
47
+ return { dataDir, progress, options, store, create }
48
+ }
49
+ function validateShape(schema, value) {
50
+ if (schema.type === 'object') {
51
+ assert.equal(typeof value, 'object'); assert.notEqual(value, null)
52
+ if (schema.additionalProperties === false) for (const key of Object.keys(value)) assert.ok(key in schema.properties, `unexpected ${key}`)
53
+ for (const [key, child] of Object.entries(schema.properties)) {
54
+ if (child.required) assert.ok(key in value, `missing ${key}`)
55
+ if (key in value) validateShape(child, value[key])
56
+ }
57
+ } else if (schema.type === 'array') {
58
+ assert.ok(Array.isArray(value)); for (const item of value) validateShape(schema.items, item)
59
+ } else if (schema.type === 'integer') assert.ok(Number.isInteger(value))
60
+ else assert.equal(typeof value, schema.type)
61
+ }
62
+
63
+ const phaseFor = (nodes, extra = {}) => ({ ...phases[0], nodes, requiredVerified: [...nodes],
64
+ evidenceRefs: seedEvidence.filter((entry) => nodes.includes(entry.node)).map((entry) => entry.id), ...extra })
65
+ const diagnostic = (nodes = ['B']) => phaseFor(nodes, {
66
+ id: 'diagnostic', title: '核实当前能力缺口', kind: 'diagnostic', finding: 'unknown',
67
+ basis: '尚无足够的实际结果证明具体短板,先进行诊断',
68
+ uncertainty: '尚未确认是否独立建模、实现稳定性或熟练度存在缺口',
69
+ diagnosticMethod: '独立完成小规模变式,记录结果并对失败代码进行判因',
70
+ evidenceRefs: [], requiredVerified: [], estimatedHours: 2,
71
+ })
72
+ const provisional = (nodes = ['D']) => phaseFor(nodes, {
73
+ id: 'future', kind: 'provisional', finding: 'unknown', basis: '该方向目前仅是与目标相关的远期假设,尚待诊断',
74
+ uncertainty: '尚未取得这些节点的训练结果,进入前须重新核实依据',
75
+ evidenceRefs: [], requiredVerified: [], estimatedHours: 0,
76
+ })
77
+ const advance = (f, revision = f.store.snapshot().revision) => f.store.execute({ action: 'advance', expectedRevision: revision, reason: '依据本阶段实际产生的检测与训练记录评估成果' })
78
+
79
+ test('assess exposes only existing record refs; rating and statuses cannot justify a training stage', () => {
80
+ const f = fixture(); f.progress.nodes = { A: { status: 'learned' }, B: { status: 'studying', rating: 1600 } }
81
+ const assessed = f.store.execute({ action: 'assess' })
82
+ assert.equal(assessed.evidenceCount, 0)
83
+ assert.equal(f.create({ phases: [phaseFor(['B'])] }).ok, false)
84
+ assert.equal(f.create({ phases: [diagnostic(), provisional()] }).ok, true)
85
+ assert.equal(f.store.snapshot().roadmap[1].kind, 'provisional')
86
+ assert.equal(f.store.execute({ action: 'assess', evidenceNodes: ['unknown'] }).ok, false)
87
+ validateShape(curriculumTool(f.store).output.schema, assessed)
88
+ })
89
+
90
+ test('training rejects fake refs, unrelated evidence and missing per-node coverage', () => {
91
+ for (const phase of [
92
+ phaseFor(['B'], { evidenceRefs: ['imaginary-record'] }),
93
+ phaseFor(['B'], { evidenceRefs: [seedEvidence.find((entry) => entry.node === 'C').id] }),
94
+ phaseFor(['B', 'C'], { evidenceRefs: [seedEvidence.find((entry) => entry.node === 'B').id] }),
95
+ phaseFor(['B'], { requiredVerified: [] }),
96
+ ]) {
97
+ const f = fixture(); assert.equal(f.create({ phases: [phase] }).ok, false)
98
+ assert.equal(f.store.load(), null)
99
+ }
100
+ })
101
+
102
+ test('positive baselines permit extension but cannot be relabeled as a measured gap', () => {
103
+ const f = fixture(); prove(f.progress, 'B')
104
+ const ref = f.store.execute({ action: 'assess', evidenceNodes: ['B'] }).evidence.find((entry) => entry.signal === 'baseline').id
105
+ assert.equal(f.create({ phases: [phaseFor(['B'], { evidenceRefs: [ref] })] }).ok, false)
106
+ assert.equal(f.create({ phases: [phaseFor(['B'], { finding: 'extension', evidenceRefs: [ref] })] }).ok, true)
107
+ })
108
+
109
+ test('ordering uses explicit prerequisites and verified training gates, never cursor assumptions', () => {
110
+ const f = fixture(); delete f.progress.nodes.A; f.progress.cursor = 'B'
111
+ assert.equal(f.create({ phases: [phaseFor(['B'])] }).ok, false)
112
+ const g = fixture()
113
+ assert.equal(g.create({ phases: [phaseFor(['D'], { id: 'early-D' }), phaseFor(['B'])] }).ok, false)
114
+ assert.equal(g.create({ phases: [phaseFor(['D', 'B'])] }).ok, false)
115
+ assert.equal(g.create({ phases: [diagnostic(), phaseFor(['D'], { id: 'after-diagnosis' })] }).ok, false)
116
+ assert.equal(g.create({ phases: [phaseFor(['B', 'D'])] }).ok, true)
117
+ })
118
+
119
+ test('planning rationale and budget are mandatory, and provisional cannot be the active phase', () => {
120
+ for (const patch of [{ basis: '凭感觉' }, { goalContribution: '' }, { priorityReason: '' }, { estimatedHours: 0 }]) {
121
+ const f = fixture(); assert.equal(f.create({ phases: [phaseFor(['B'], patch)] }).ok, false)
122
+ }
123
+ const f = fixture(); assert.equal(f.create({ phases: [provisional(['B'])] }).ok, false)
124
+ })
125
+
126
+ test('budget reports time pressure and uncertainty without pretending a weekly average is a daily schedule', () => {
127
+ const f = fixture(); const state = f.create({ weeklyHours: 7, deadline: '2026-10-09', phases: [phaseFor(['B'], { estimatedHours: 8 })] })
128
+ assert.equal(state.ok, true); assert.equal(state.remainingEstimatedHours, 8)
129
+ assert.match(state.budgetWarnings.join(' '), /高于目标日期/)
130
+ const g = fixture(); const unknown = g.create({ weeklyHours: 0, phases: [diagnostic(), provisional()] })
131
+ assert.equal(unknown.estimatedWeeks, 0); assert.match(unknown.budgetWarnings.join(' '), /未确认/)
132
+ assert.match(unknown.budgetWarnings.join(' '), /尚未估时/)
133
+ })
134
+
135
+ test('a changed source invalidates its reference and requires an explicit rebase', () => {
136
+ const f = fixture(); f.create()
137
+ f.progress.nodes.B.checks[0].minutes = 50
138
+ const state = f.store.snapshot(); assert.equal(state.needsReview, true)
139
+ assert.equal(f.store.assignment({ node: 'B', revision: 1 }).ok, false)
140
+ assert.equal(advance(f).ok, false)
141
+ const refs = f.store.execute({ action: 'assess' }).evidence
142
+ const rebased = phases.map((phase) => ({ ...phase, evidenceRefs: refs.filter((entry) => phase.nodes.includes(entry.node)).map((entry) => entry.id) }))
143
+ assert.equal(f.store.execute({ action: 'revise', expectedRevision: 1, phases: rebased, reason: '原检测记录有实质修订,按当前真实内容重新引用' }).ok, true)
144
+ })
145
+
146
+ test('diagnostic cannot advance on old evidence, change into training or delete a probe', () => {
147
+ const f = fixture(); f.create({ phases: [diagnostic(['B', 'C']), provisional()] })
148
+ assert.equal(advance(f).ok, false)
149
+ assert.match(advance(f).reject, /新诊断/)
150
+ for (const replacement of [phaseFor(['B', 'C'], { id: 'diagnostic' }), diagnostic(['B'])]) {
151
+ assert.equal(f.store.execute({ action: 'revise', expectedRevision: 1, phases: [replacement, provisional()], reason: '尝试跳过尚未完成的诊断直接进入训练' }).ok, false)
152
+ }
153
+ assert.equal(f.store.snapshot().revision, 1)
154
+ })
155
+
156
+ test('diagnosis results allow an evidence-grounded follow-up; next training requires new performance', () => {
157
+ const f = fixture(); f.create({ phases: [diagnostic()] })
158
+ f.progress.nodes.B.checks.push({ date: '2026-10-09', outcome: 'failed' })
159
+ assert.equal(advance(f).ok, false); assert.match(advance(f).reject, /后续路线/)
160
+ const training = phaseFor(['B'], { id: 'fix-B', evidenceRefs: f.store.execute({ action: 'assess', evidenceNodes: ['B'] }).evidence.map((entry) => entry.id) })
161
+ const revised = f.store.execute({ action: 'revise', expectedRevision: 1, phases: [diagnostic(), training], reason: '根据本阶段新诊断结果追加针对性的训练阶段' })
162
+ assert.equal(revised.ok, true, revised.reject)
163
+ const entered = advance(f); assert.equal(entered.ok, true, entered.reject); assert.equal(entered.phaseId, 'fix-B')
164
+ assert.equal(advance(f).ok, false)
165
+ prove(f.progress, 'B')
166
+ assert.equal(advance(f).status, 'complete')
167
+ })
168
+
169
+ test('a provisional next phase cannot be entered even after diagnostic records exist', () => {
170
+ const f = fixture(); f.create({ phases: [diagnostic(), provisional()] })
171
+ f.progress.nodes.B.checks.push({ date: '2026-10-09', outcome: 'failed' })
172
+ const result = advance(f); assert.equal(result.ok, false); assert.match(result.reject, /下一阶段仍待定/)
173
+ assert.equal(f.store.snapshot().phaseId, 'diagnostic')
174
+ })
175
+
176
+ test('verified flags and old/backdated checks cannot substitute for new stage performance', () => {
177
+ const f = fixture(); prove(f.progress, 'B'); f.create({ phases: [phaseFor(['B'])] })
178
+ assert.equal(advance(f).ok, false)
179
+ f.progress.nodes.B.checks.push({ date: '2026-10-08', outcome: 'passed' })
180
+ assert.equal(advance(f).ok, false)
181
+ prove(f.progress, 'B'); assert.equal(advance(f).status, 'complete')
182
+ })
183
+
184
+ test('note edits, changed old results and mirrored imports do not constitute new diagnostic events', () => {
185
+ const f = fixture()
186
+ f.progress.nodes.B.passes = [{ date: '2026-10-09', problemId: 'P1', solved: false, independent: false, minutes: 40, note: '初始记录' }]
187
+ f.create({ phases: [diagnostic(), provisional()] })
188
+ f.progress.nodes.B.passes[0].note = '仅修改说明'
189
+ assert.deepEqual(f.store.snapshot().missingFreshEvidence, ['B'])
190
+ f.progress.nodes.B.passes[0].solved = true; f.progress.nodes.B.passes[0].independent = true
191
+ assert.deepEqual(f.store.snapshot().missingFreshEvidence, ['B'])
192
+ const g = fixture(); g.progress.nodes.B = {}
193
+ const schedule = { days: { '2026-10-09': { actual: [{ node: 'B', problemId: 'P1', solved: false, independent: false, actualMin: 40 }] } } }
194
+ const store = createCurriculumStore({ ...g.options, loadSchedule: () => schedule })
195
+ assert.equal(store.execute({ action: 'create', expectedRevision: 0, goal: '核实长期训练所需的能力起点', phases: [diagnostic(), provisional()], reason: '在只有日程记录的情况下先建立能力诊断阶段' }).ok, true)
196
+ g.progress.nodes.B.passes = [{ date: '2026-10-09', problemId: 'P1', solved: false, independent: false, minutes: 40 }]
197
+ assert.deepEqual(store.snapshot().missingFreshEvidence, ['B'])
198
+ })
199
+
200
+ test('adding a training target snapshots its existing observations; budget-only revision preserves progress', () => {
201
+ const f = fixture(); f.create({ phases: [phaseFor(['B'])] }); prove(f.progress, 'C')
202
+ assert.equal(f.store.execute({ action: 'revise', expectedRevision: 1, phases: [phaseFor(['B', 'C'])], reason: '根据实际记录扩展当前阶段能力目标,重新验收新增节点' }).ok, true)
203
+ assert.deepEqual(f.store.snapshot().missingFreshEvidence, ['B', 'C'])
204
+ const g = fixture(); g.create({ phases: [phaseFor(['B'])] }); prove(g.progress, 'B')
205
+ assert.equal(g.store.execute({ action: 'revise', expectedRevision: 1, phases: [phaseFor(['B'], { estimatedHours: 10 })], reason: '可用时间改变,仅调整时间估算,保持能力目标和依据' }).ok, true)
206
+ assert.deepEqual(g.store.snapshot().missingFreshEvidence, [])
207
+ })
208
+
209
+ test('v1 route remains readable without writes; migration preserves completed stages and history', () => {
210
+ const f = fixture(); f.create(); prove(f.progress, 'B'); prove(f.progress, 'C')
211
+ const old = parse(readFileSync(f.store.path, 'utf8')); old.version = 1; old.current = 1
212
+ old.phases = old.phases.map(({ id, title, outcome, nodes, exitCriteria, requiredVerified }) => ({ id, title, outcome, nodes, exitCriteria, requiredVerified }))
213
+ delete old.enteredAt; delete old.entryEvidenceRefs; delete old.entryEvidenceEvents
214
+ const bytes = stringify(old); writeFileSync(f.store.path, bytes)
215
+ const state = f.store.snapshot(); assert.equal(state.needsReview, true)
216
+ assert.equal(readFileSync(f.store.path, 'utf8'), bytes)
217
+ assert.equal(f.store.assignment({ node: 'D', revision: 1 }).ok, false)
218
+ assert.equal(advance(f).ok, false)
219
+ const completed = f.store.load().phases[0]
220
+ const result = f.store.execute({ action: 'revise', expectedRevision: 1, phases: [completed, phases[1]], reason: '为旧路线补齐未完成阶段的真实依据,保留已经完成的阶段' })
221
+ assert.equal(result.ok, true, result.reject); assert.equal(result.revision, 2)
222
+ const migrated = parse(readFileSync(f.store.path, 'utf8'))
223
+ assert.equal(migrated.version, 2); assert.equal(migrated.phases[0].kind, 'legacy')
224
+ assert.equal(migrated.history[1].previous.version, 1)
225
+ })
226
+
227
+ test('course persists across store instances without duplicating progress', () => {
228
+ const f = fixture(); assert.equal(f.create().ok, true)
229
+ const restarted = createCurriculumStore(f.options)
230
+ assert.equal(restarted.snapshot().goal, '区域赛稳定表现,长期向 WF 发展')
231
+ assert.equal(restarted.snapshot().phaseId, 'foundation')
232
+ assert.deepEqual(restarted.snapshot().missingVerified, ['B', 'C'])
233
+ assert.equal(parse(readFileSync(f.store.path, 'utf8')).nodes, undefined)
234
+ Object.assign(f.progress.nodes.B, { status: 'verified' })
235
+ assert.deepEqual(restarted.snapshot().missingVerified, ['C'])
236
+ })
237
+
238
+ test('stale writes cannot overwrite a changed goal, and revision history keeps old snapshots', () => {
239
+ const f = fixture(); f.create()
240
+ const edited = f.store.execute({ action: 'revise', expectedRevision: 1, goal: '按证据修订后的能力目标', reason: '用户修改目标,保留当前阶段并调整远期路线' })
241
+ assert.equal(edited.revision, 2)
242
+ assert.equal(f.store.execute({ action: 'revise', expectedRevision: 1, goal: '旧目标', reason: '使用旧版本尝试更新课程目标' }).ok, false)
243
+ assert.equal(f.store.snapshot().goal, '按证据修订后的能力目标')
244
+ const doc = parse(readFileSync(f.store.path, 'utf8'))
245
+ assert.equal(doc.history[1].previous.goal, '区域赛稳定表现,长期向 WF 发展')
246
+ })
247
+
248
+ test('unknown map nodes, duplicate phases, invalid dates and future phase focus are rejected', () => {
249
+ for (const extra of [
250
+ { phases: [{ ...phases[0], nodes: ['unknown'] }] },
251
+ { phases: [phases[0], phases[0]] }, { deadline: '2026-02-30' },
252
+ { focusNodes: ['D'] }, { weeklyHours: 169 },
253
+ ]) {
254
+ const f = fixture(); assert.equal(f.create(extra).ok, false); assert.equal(f.store.load(), null)
255
+ }
256
+ })
257
+
258
+ test('active course cannot be silently replaced, and failed validation preserves the file', () => {
259
+ const f = fixture(); f.create(); const before = readFileSync(f.store.path, 'utf8')
260
+ assert.equal(f.create({ expectedRevision: 1 }).ok, false)
261
+ assert.equal(f.store.execute({ action: 'revise', expectedRevision: 1, reason: '尚未给出足够修改依据', focusNodes: ['D'] }).ok, false)
262
+ assert.equal(readFileSync(f.store.path, 'utf8'), before)
263
+ })
264
+
265
+ test('assignment is bound to current course revision and focus, with explicit detours', () => {
266
+ const f = fixture(); f.create({ focusNodes: ['B'] })
267
+ assert.equal(f.store.assignment({ node: 'B' }).ok, false)
268
+ assert.equal(f.store.assignment({ node: 'C', revision: 1 }).ok, false)
269
+ assert.equal(f.store.assignment({ node: 'D', revision: 1, purpose: 'remediation' }).ok, false)
270
+ const detour = f.store.assignment({ node: 'C', revision: 1, purpose: 'remediation', reason: '补当前阶段暴露的机制缺口,完成后回到 B' })
271
+ assert.equal(detour.ok, true); assert.equal(detour.binding.phaseId, 'foundation')
272
+ assert.equal(f.store.snapshot().phaseId, 'foundation')
273
+ assert.equal(f.store.assignment({ node: 'D', revision: 1, purpose: 'review', reason: '暂时复习同类知识点,之后返回主线' }).ok, false)
274
+ assert.equal(f.store.assignment({ node: 'A', revision: 1, purpose: 'review', reason: '复习已经学过的前置,随后回到 B 的变式' }).ok, true)
275
+ })
276
+
277
+ test('expired weekly focus pauses new progression without changing long-term course', () => {
278
+ const f = fixture(); f.create({ focusNodes: ['B'], focusUntil: '2026-10-08' })
279
+ assert.equal(f.store.snapshot().focusExpired, true)
280
+ assert.equal(f.store.assignment({ node: 'B', revision: 1 }).ok, false)
281
+ assert.match(f.store.context(), /本周主线已到期/)
282
+ assert.equal(f.store.snapshot().goal, '区域赛稳定表现,长期向 WF 发展')
283
+ })
284
+
285
+ test('unfinished bound action stops a new unit; completing it permits the next one', () => {
286
+ const f = fixture(); f.create()
287
+ f.progress.pendingActions.B = { curriculum: { phaseId: 'foundation' } }
288
+ assert.equal(f.store.assignment({ node: 'C', revision: 1 }).ok, false)
289
+ assert.match(f.store.context(), /先继续/)
290
+ delete f.progress.pendingActions.B
291
+ assert.equal(f.store.assignment({ node: 'C', revision: 1 }).ok, true)
292
+ })
293
+
294
+ test('existing assignments survive introducing a course and no-course behavior stays available', () => {
295
+ const f = fixture()
296
+ assert.equal(f.store.assignment({ node: 'B' }).ok, true)
297
+ assert.equal(f.store.assignment({ node: 'B' }).binding, null)
298
+ f.progress.pendingActions.A = { deliverable: '升级前已布置的动作' }
299
+ f.create()
300
+ assert.deepEqual(f.store.snapshot().pendingNodes, ['A'])
301
+ assert.equal(f.store.assignment({ node: 'B', revision: 1 }).ok, false)
302
+ delete f.progress.pendingActions.A
303
+ assert.equal(f.store.assignment({ node: 'B', revision: 1 }).ok, true)
304
+ })
305
+
306
+ test('phase advancement requires verified facts, no pending actions, and no ungraded paper', () => {
307
+ const f = fixture(); f.create()
308
+ const advance = () => f.store.execute({ action: 'advance', expectedRevision: 1, reason: '根据独立变式及延迟检测记录评估阶段成果' })
309
+ assert.equal(advance().ok, false)
310
+ prove(f.progress, 'B'); prove(f.progress, 'C')
311
+ f.progress.pendingActions.B = { curriculum: { phaseId: 'foundation' } }
312
+ assert.equal(advance().ok, false)
313
+ delete f.progress.pendingActions.B; f.progress.pending = { node: 'B' }
314
+ assert.equal(advance().ok, false)
315
+ f.progress.pending = null
316
+ const next = advance(); assert.equal(next.ok, true); assert.equal(next.phaseId, 'transfer')
317
+ assert.deepEqual(next.missingVerified, ['D'])
318
+ assert.equal(f.store.execute({ action: 'revise', expectedRevision: 2, phases: [{ ...phases[0], outcome: '重写过去' }, phases[1]], reason: '尝试通过修订重写之前已完成的能力阶段' }).ok, false)
319
+ })
320
+
321
+ test('all phases can finish explicitly and a new route retains the previous audit history', () => {
322
+ const f = fixture(); f.create({ phases: [phases[0]] })
323
+ prove(f.progress, 'B'); prove(f.progress, 'C')
324
+ assert.equal(f.store.snapshot().status, 'active')
325
+ const done = f.store.execute({ action: 'advance', expectedRevision: 1, reason: '依据两次独立检测及迁移任务完成最终验收' })
326
+ assert.equal(done.status, 'complete')
327
+ assert.equal(f.store.assignment({ node: 'B', revision: 2 }).ok, false)
328
+ assert.equal(f.create({ expectedRevision: 2 }).ok, true)
329
+ assert.equal(f.store.snapshot().history.length, 3)
330
+ })
331
+
332
+ test('broken course is surfaced and never treated as an empty plan', () => {
333
+ const f = fixture(); writeFileSync(f.store.path, 'revision: broken\n')
334
+ const result = f.create(); assert.equal(result.ok, false)
335
+ assert.match(f.store.context(), /读取失败/)
336
+ assert.equal(readFileSync(f.store.path, 'utf8'), 'revision: broken\n')
337
+ })
338
+
339
+ test('course tool output matches its declared schema on success, rejection and absence', () => {
340
+ const f = fixture(); const tool = curriculumTool(f.store)
341
+ for (const result of [f.store.execute({ action: 'read' }), f.create(), f.store.execute({ action: 'advance', expectedRevision: 1, reason: '检查未验收阶段是否能够被强行推进' })]) validateShape(tool.output.schema, result)
342
+ })
343
+
344
+ test('partly damaged course fields are rejected with a valid output and never overwritten', () => {
345
+ for (const change of [
346
+ (doc) => { doc.goal = 123 }, (doc) => { delete doc.baseline },
347
+ (doc) => { doc.history = [null] }, (doc) => { doc.updated = 'invalid' },
348
+ (doc) => { doc.focusNodes = ['B', 'B'] }, (doc) => { doc.weeklyHours = 169 },
349
+ ]) {
350
+ const f = fixture(); f.create()
351
+ const doc = parse(readFileSync(f.store.path, 'utf8')); change(doc)
352
+ const broken = stringify(doc); writeFileSync(f.store.path, broken)
353
+ const result = f.store.execute({ action: 'read' })
354
+ assert.equal(result.ok, false)
355
+ validateShape(curriculumTool(f.store).output.schema, result)
356
+ assert.equal(readFileSync(f.store.path, 'utf8'), broken)
357
+ }
358
+ })
359
+
360
+ test('plugin integration: cross-branch course assignments, detours, logging and new-session context', async () => {
361
+ const dataDir = mkdtempSync(join(testBase, 'coach-course-integration-'))
362
+ writeFileSync(join(dataDir, 'MAP.yaml'), 'meta: {version: 3}\nnodes:\n' + [...byId].map(([id, n]) => ` - id: ${id}\n name: ${id}\n domain: 测试\n tier: core\n depends: [${n.depends.join(', ')}]\n`).join(''))
363
+ writeFileSync(join(dataDir, 'PROGRESS.yaml'), stringify({ version: 1, cursor: 'B', nodes: { ...structuredClone(seedNodes), B: { ...seedNodes.B, status: 'studying' }, C: { ...seedNodes.C, status: 'studying' } } }))
364
+ process.env.COACH_DATA_DIR = dataDir
365
+ process.env.COACH_DB = join(dataDir, 'unused.db')
366
+ const tools = new Map(); let hook
367
+ const { apply } = await import('../index.js?curriculum-integration')
368
+ apply({ tools: { register: (t) => tools.set(t.name, t) }, inject() {}, on: (event, fn) => { if (event === 'agent/pre-step') hook = fn } })
369
+ const course = tools.get('coach_curriculum'); const assign = tools.get('coach_assign')
370
+ const created = await course.execute({ action: 'create', expectedRevision: 0, goal: '测试长期路线', phases, reason: '依据训练表现建立可延续的阶段课程' })
371
+ assert.equal(created.ok, true, created.reject)
372
+ const candidates = await tools.get('coach_next').execute({ cursor: 'B' })
373
+ const nextText = tools.get('coach_next').output.render({}, candidates)[0].text
374
+ assert.match(nextText, /测试长期路线/)
375
+ assert.doesNotMatch(nextText, /得先把游标挪过去/)
376
+ const gradeText = tools.get('coach_grade').output.render({}, {
377
+ ok: true, outcome: 'passed', nodeName: 'B', score: 3, maxScore: 3, passScore: 2,
378
+ minutes: 40, limit: 60, checks: 1, problems: [], verifiedAt: 1600, selfReported: 0,
379
+ })[0].text
380
+ assert.match(gradeText, /单点过卷不会自动推进阶段/)
381
+ assert.doesNotMatch(gradeText, /推进,别回头/)
382
+ const args = { node: 'C', deliverable: '独立变式代码与思路', why: '检验当前阶段的建模能力', curriculumRevision: 1 }
383
+ const accepted = await assign.execute(args); assert.equal(accepted.accepted, true, accepted.reject)
384
+ assert.match(accepted.why, /稳定基础能力/)
385
+ assert.equal(parse(readFileSync(join(dataDir, 'PROGRESS.yaml'), 'utf8')).pendingActions.C.curriculum.phaseId, 'foundation')
386
+ assert.equal((await assign.execute({ ...args, node: 'B' })).accepted, false)
387
+ const decision = await hook({ messages: [], step: 1 }, async () => ({ kind: 'enter', messages: [] }))
388
+ assert.match(JSON.stringify(decision.messages), /测试长期路线/)
389
+ assert.match(JSON.stringify(decision.messages), /当前未完成单元/)
390
+ const logged = await tools.get('coach_log').execute({ node: 'C', actualMin: 40, solved: true, independent: true })
391
+ assert.equal(logged.actionCleared, true)
392
+ assert.equal((await course.execute({ action: 'read' })).phaseId, 'foundation')
393
+ const offRoute = await assign.execute({ ...args, node: 'D', teachMinutes: 10 })
394
+ assert.equal(offRoute.accepted, false); assert.match(offRoute.reject, /当前阶段主线/)
395
+ const remediation = await assign.execute({ ...args, node: 'E', teachMinutes: 10, purpose: 'remediation', routeReason: '补当前阶段建模失败的机制,检验后回到主线' })
396
+ assert.equal(remediation.accepted, false); assert.match(remediation.reject, /前置/)
397
+ validateShape(assign.output.schema, accepted); validateShape(assign.output.schema, offRoute)
398
+ writeFileSync(join(dataDir, 'PROGRESS.yaml'), 'version: 1\ncursor: ""\nnodes: {}\n')
399
+ const fresh = await hook({ messages: [], step: 1 }, async () => ({ kind: 'enter', messages: [] }))
400
+ assert.match(JSON.stringify(fresh.messages), /测试长期路线/)
401
+ const later = await hook({ messages: [], step: 2 }, async () => ({ kind: 'enter', messages: [] }))
402
+ assert.equal(later.messages.length, 0)
403
+ })