acmer-coach 2.4.0 → 2.5.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,24 @@ learned/verified 记录,或路线中在此前安排的必验训练,不能从
117
121
 
118
122
  详见 [长期课程机制](docs/长期课程机制.md)。
119
123
 
124
+ ### 2.5:接着教,判断仍归小鲸
125
+
126
+ `coach_note` 只做一件事:把一个知识点的教学判断和接续点留在现有
127
+ `PROGRESS.yaml` 的 `nodes.<id>.lessonNotes`。`read` 查看最近笔记,`write` 保存
128
+ `summary` 与 `nextStep`;不新增课程状态机、聊天数据库或另一份掌握记录。
129
+ `coach_status` 的 `teachingContext` 和每轮首步状态会带上短笔记,需要原话时再查原生会话。
130
+ 未指定节点时,读取优先显示最近有笔记的未完动作,再看游标;没有游标则看最近笔记。
131
+ 写入默认使用游标,显式 `node` 优先,不会为了写笔记移动游标。
132
+
133
+ `studentQuote` 可选。填写时核对当前 dsh 会话中的真实学生消息,自动记录来源;
134
+ 插件消息、压缩摘要和其他会话不能冒充学生回答。核对原话存在不等于判断学生理解,
135
+ 没有原话的笔记也可保存,但标作教练判断。笔记不会改进度状态、游标、待办或检测结果。
136
+ 旧宿主没有原生会话查询时,普通笔记可用,带原话的写入会明确拒绝。
137
+
138
+ `teachMinutes` 现在只保存讲授计划,不再自动写“已经教过”的日期。实际进入学习由
139
+ 教练显式 `coach_mark studying` 记录;已有接触记录继续可用,但不证明理解。
140
+ 这版验证的是记录、来源和续课机制;真实小鲸对话的教学效果仍需观察。
141
+
120
142
  ### 2.4:证据、验收与教学依据
121
143
 
122
144
  `coach_focus` 分开近30天的独立成功、待确认观察、已确认短板,按不同场次计数。
@@ -250,11 +272,12 @@ dsh 的会话上下文有限 —— 新开一个会话,教练不记得上次
250
272
 
251
273
  | 文件 | 作用 |
252
274
  |---|---|
253
- | `index.js` | 插件本体(host 端,21 个工具 + 规则注入) |
275
+ | `index.js` | 插件本体(host 端,31 个工具 + 规则注入) |
254
276
  | `lib/setup.js` | 环境搭建 + CF 公开数据同步 |
255
277
  | `lib/curriculum.js` | 持久课程路线、阶段验收门槛、版本与修订历史 |
256
278
  | `lib/curriculum-evidence.js` | 从实际记录派生证据引用与事件身份,去重课表和训练记录 |
257
279
  | `lib/curriculum-planning.js` | 阶段编排依据、前置顺序与进入阶段后的新证据检查 |
280
+ | `lib/teaching.js` | 节点续课笔记,复用进度文件与 dsh 原生会话查询 |
258
281
  | `lib/client.js` | 浏览器端:会话头部「技能树」按钮 + 弹层 |
259
282
  | `package.json` | 声明 `dsh.bundle.patch` + `dsh.client` |
260
283
  | `cordis.patch.yml` | bundle 层的 insert 行 |
@@ -276,7 +299,7 @@ dsh 的会话上下文有限 —— 新开一个会话,教练不记得上次
276
299
  |---|---|---|
277
300
  | **规则** | `assets/rules/coach-rules.md` | 插件用 `systemPrompt.section()` 注进 system prompt(和人设同一条路)。**改它要重启 dsh** |
278
301
  | **知识** | 地图 4 份 + OI Wiki 检索库 | `coach_setup init` 铺到数据目录;OI Wiki 直接从包里读 |
279
- | **工具** | 21 个 | 装完就在 |
302
+ | **工具** | 31 个 | 装完就在 |
280
303
 
281
304
  规则进的是 **system prompt,不是消息流**:行为约束每轮都得在场,
282
305
  而消息流会被长对话把尾巴挤掉。文本是**静态**的(只在加载时读一次)——
@@ -22,7 +22,9 @@
22
22
  * `coach_focus` 只汇总近30天不同场次:成功起点不算问题,重复提交/复盘不增加场次数,
23
23
  修改旧复盘不把它变成最近发生。分数是取舍参考,不是掌握度、出题概率或奖牌概率。
24
24
 
25
- * 有比赛目标时,先调 `coach_target read`,再调 `coach_focus`。没有目标时不能用
25
+ * 有比赛目标时,结合 `coach_target read` 和 `coach_focus` 的信息取舍;当前上下文已有
26
+ 足够信息时不必重复查询,也不要求固定调用顺序。能力排名是参考,排在前三之外或尚未
27
+ 映射到能力的节点可以训练,说明理由即可。没有目标时不能用
26
28
  rating、游标或地图后继猜长期方向;先建立比赛、日期、结果目标、团队模式、时间预算
27
29
  和能力优先级。
28
30
  * `coach_focus` 给的是比赛能力重点,不是知识点清单。每次训练必须说明它服务哪个能力、
@@ -92,9 +94,10 @@
92
94
 
93
95
  下面九条是硬规则,每条都能机械检查。违反就是没做好本职工作:
94
96
 
95
- 1. **给训练指令前,必须先调 `coach_target`/`coach_focus`,再调 `coach_next`**。
96
- 不许只凭 rating 或地图后继挑知识点 ——
97
- 它可能在地图里根本不存在,或者前置还没满足。
97
+ 1. **给训练指令前,核对目标、当前重点和地图前置**。
98
+ 用 `coach_target`、`coach_focus`、`coach_next` 按需补足信息;上下文已有足够依据时
99
+ 不重复读取,不规定调用顺序。不许只凭 rating 或地图后继挑知识点,
100
+ 节点必须真实存在,前置必须满足。
98
101
  2. **每条指令必须带地图节点 id**。节点 id 只能从地图取到,
99
102
  所以这条会自动逼你先读地图。
100
103
  3. **一次只给一个动作。** 禁止候选列表、禁止「你可以考虑…」、
@@ -186,16 +189,18 @@
186
189
 
187
190
  ### 复习别把新知识挤没
188
191
 
189
- 这套系统的价值在**往前推**,不在原地保温。一个月的表里排满复习,
190
- 等于这个月没往前走。
192
+ 训练要改善目标比赛中的表现。新知识、复习和迁移都可能带来改善,
193
+ 安排哪一个要看目标、已有表现和投入收益。
191
194
 
192
195
  **但他自己会想先复习** —— 复习舒服(做过的东西有把握)、
193
196
  开新知识点难受(进入状态有成本)。所以这个平衡**得你把**,
194
197
  不能等他主动说"我想学新的":
195
198
 
196
199
  * 排课时,复习块**按火候排**(放得最久的那几个才排),不是按"他提了哪个"排;
197
- * 比火候更靠前的是**推进**:只要他状态还行、时间够,块里就该有一个是开新知识点的;
198
- * 反过来说,**火候高到离谱的(放了一两个月)也不能不管** —— 那种是该重讲,不是该复习。
200
+ * **推进不等于开新知识点**:按目标与实际表现选择新课、复习、迁移或 VP,
201
+ 不为填课表硬开新点,也不让熟悉题反复练习代替真实改善;
202
+ * **放了一两个月的知识点先核对保持情况**,再决定检测、复习或重讲,
203
+ 不把间隔长直接当作已经忘光。
199
204
  * **提法按规则 3:一次一个动作** ——「这块我建议验一下,90 分钟三题,做不做?」
200
205
  并且**说清你为什么觉得该验了**(拿上面的记录当证据)。
201
206
  他说做,你才走下面三步。
@@ -339,9 +344,16 @@
339
344
  他说会了就别再给。
340
345
  * **注释密度跟他的样例走** —— 样例里注释少就少,别逐行注释。
341
346
 
342
- **讲完才出题**:讲完用 `coach_assign` 布置,**带上 `teachMinutes`** ——
343
- 没有那一段它会被拒绝(新知识点不给 `teachMinutes` 就是又回到"直接出题"了)。
344
- 讲多久**你定**,别参考任何固定值:看你判断他需要多少。
347
+ **新课动作要留出教学时间**:用 `coach_assign` 布置时带 `teachMinutes`,
348
+ 它是计划时长,不证明已经讲过,也不证明学生理解。实际开始学习后可用 `coach_mark`
349
+ 记为 studying;是否学会仍需学生表现和检测。讲多久由你根据需要判断。
350
+
351
+ **续课只留必要笔记**:发生值得接着讲的进展或中断时,用 `coach_note write` 保存
352
+ 当前判断、尚未确认之处和下一次接续点,不记录整段聊天、不每次回复都写。
353
+ `coach_status` 和新会话状态会带上最近笔记,需要细节再用 `coach_note read`。
354
+ 引用学生回应可填 `studentQuote`;工具只核对当前会话里真实学生原话,身份和位置自动记录,
355
+ 不要让学生提供消息 ID。核对成功只证明原话存在,不证明理解正确。没有原话依据的笔记
356
+ 仍可保存,但必须当作教练判断;笔记不改游标、进度、待办或检测结果,不替代掌握证据。
345
357
 
346
358
  ⚠️ **例外:游标自己那个节点不算"新知识点"** —— 接着做,不逼你再讲一遍。
347
359
  **但例外有条件:这个节点得真讲过**(有教学痕迹)。游标可以被挪到一个
@@ -457,7 +469,8 @@
457
469
 
458
470
  ## 训练数据操作纪律(P0)
459
471
 
460
- * 修改训练数据(地图、进度、日程)前,必须:① 告知将改什么 ② 等确认后执行。
472
+ * 已授权课程内的事实记录、续课笔记和做题用时正常保存,不重复要求确认。
473
+ 替换或删除个人数据、大幅改变目标或课程时,先说明将改什么,等确认后执行。
461
474
  * 禁止擅自修改他人的训练数据。
462
475
  * 挑题之前先查 AC 记录,不重复喂已经会的题。
463
476
 
@@ -506,8 +519,8 @@
506
519
  (一个写、一个验、一个造 hack 数据)。
507
520
  末尾并行死磕多道难题是 ICPC 最常见的败因 —— 风险是最后一道都做不出。
508
521
  * 每场记一句:**题目是不是全都被读过**(读漏题比做不出更亏)。
509
- * **赛前 2 周不塞新技巧**:只确认已经练到"不用想"的东西 ——
510
- 压力下只有自动化了的技能不掉线。
522
+ * **临近比赛通常优先巩固与模拟**:按剩余时间、掌握基础和预期收益取舍;
523
+ 必要的新内容可安排,说明收益、风险和验收方式,不按固定两周一律禁止。
511
524
 
512
525
  ## 紧急停止(最高优先级)
513
526
 
@@ -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') {
@@ -73,7 +73,7 @@ const onlyA = namesA.filter((n) => !namesB.includes(n))
73
73
  check('开源版没有丢掉任何原有工具', onlyB.length === 0, onlyB.join(',') || '(没丢)')
74
74
  // 新增的只能是「搭环境 / 拉数据 / 查知识 / 长期课程」这几类 —— 它们补的正是
75
75
  // 开源版缺的那些基础设施(原版靠本地已有的库和 skill,别人没有)。
76
- const EXPECTED_NEW = new Set(['coach_setup', 'coach_wiki', 'coach_import', 'coach_curriculum', 'coach_target', 'coach_focus', 'coach_scope', 'coach_postmortem', 'coach_vp_import', 'coach_vp_connect', 'coach_vp_sync', 'coach_vp_contests', 'coach_vp_replay'])
76
+ const EXPECTED_NEW = new Set(['coach_setup', 'coach_wiki', 'coach_import', 'coach_curriculum', 'coach_note', 'coach_target', 'coach_focus', 'coach_scope', 'coach_postmortem', 'coach_vp_import', 'coach_vp_connect', 'coach_vp_sync', 'coach_vp_contests', 'coach_vp_replay'])
77
77
  check('新增的都在预期内(搭建 / 检索 / 课程 / 比赛 / VP)',
78
78
  onlyA.every((n) => EXPECTED_NEW.has(n)), onlyA.join(',') || '(无新增)')
79
79
  // 对照副本可能也已经升级到 20/21 个工具;检查原工具集合,不能拿固定数量拒绝新版。
@@ -130,10 +130,14 @@ for (const [tool, args, label] of CALLS) {
130
130
  try { rb = norm(await tb.execute(args, undefined)) } catch (e) { eb = e.message }
131
131
  // Legacy hosts have no goal layer; only its explicit no-target defaults may be absent.
132
132
  if (tool === 'coach_assign' && rb && !('targetAligned' in rb) &&
133
- (ra?.targetAligned === true || (ra?.accepted === false && rb.accepted === false && ra.reject === rb.reject && ra.targetAligned === false)) &&
133
+ ra?.targetAligned === false &&
134
134
  Array.isArray(ra.competencies) && !ra.competencies.length) {
135
135
  delete ra.targetAligned; delete ra.competencies
136
136
  }
137
+ // A legacy host cannot return the new empty continuation field.
138
+ if (tool === 'coach_status' && rb && !('teachingContext' in rb) && ra?.teachingContext === '') {
139
+ delete ra.teachingContext
140
+ }
137
141
  const sa = JSON.stringify(ra ?? { ERR: ea }), sb = JSON.stringify(rb ?? { ERR: eb })
138
142
  if (sa === sb) { check(`${tool} · ${label}`, true) }
139
143
  else {
@@ -154,8 +158,16 @@ for (const [tool, args, label] of CALLS.filter((c) => c[0] !== 'coach_diagnose')
154
158
  try { va = await ta.execute(args, undefined) } catch { continue }
155
159
  try { vb = await tb.execute(args, undefined) } catch { continue }
156
160
  const ra = strip(ta.output.render(args, va)[0]?.text ?? '')
157
- const rb = strip(tb.output.render(args, vb)[0]?.text ?? '')
158
- const same = ra === rb
161
+ const legacyText = tb.output.render(args, vb)[0]?.text ?? ''
162
+ // The old lecture label overstated its evidence; only this intentional label changes.
163
+ const rb = strip(tool === 'coach_status'
164
+ ? legacyText.replace(/ 讲于 (\d{4}-\d{2}-\d{2})/g, ' 接触记录 $1(不证明理解)')
165
+ : legacyText)
166
+ // Only the changed goal-led advice is excluded; schedule facts still match byte for byte.
167
+ const scheduleAdvice = (text) => text
168
+ .replace(/^⚠️ \*\*复习别把新知识挤没了。\*\*.*剩下的时间留给推进。$/m, '<COACH_ADVICE>')
169
+ .replace(/^根据目标、剩余时间和实际表现,选择新课、复习、迁移或 VP。上面的间隔只供判断;不要为了填满课表持续开新知识点。$/m, '<COACH_ADVICE>')
170
+ const same = tool === 'coach_schedule' ? scheduleAdvice(ra) === scheduleAdvice(rb) : ra === rb
159
171
  check(`渲染 · ${tool} · ${label}`, same, same ? '' : '文本不一致')
160
172
  }
161
173
 
package/dev/run-all.sh CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bash
2
2
  # 一条命令跑全套验收。
3
3
  #
4
- # 八套:
4
+ # 验收项:
5
5
  # verify / verify-shape 主验收和全工具形状扫描
6
6
  # w1-check 解绑、夹具闸门、规则注入
7
7
  # wiki-check OI Wiki 检索
@@ -9,6 +9,7 @@
9
9
  # curriculum-check 长期课程、版本、阶段验收与任务关联
10
10
  # curriculum-evidence 编排证据目录、引用与事件身份
11
11
  # strategy-check 比赛目标、VP 证据、复盘与重点选择
12
+ # teaching 续课笔记、学生原话与进度保留
12
13
  # parity-check 和线上那份的保真度对比
13
14
  #
14
15
  # 主验收跑的是**已安装副本**(不是源码)—— 那才是运行时真正加载的东西。
@@ -46,7 +47,7 @@ run() { # run <名字> <命令...>
46
47
  echo "✓ $(echo "$out" | tail -1)"
47
48
  else
48
49
  echo "✗"
49
- echo "$out" | tail -25 | sed 's/^/ /'
50
+ echo "$out" | sed 's/^/ /'
50
51
  RC=1
51
52
  fi
52
53
  }
@@ -67,6 +68,7 @@ run "evidence" node --test dev/curriculum-evidence-check.mjs
67
68
  run "strategy" node --test dev/strategy-check.mjs
68
69
  run "catalog" node --test dev/catalog-check.mjs
69
70
  run "coach-contract" node --test dev/coaching-contract-check.mjs
71
+ run "teaching" node --test dev/teaching-check.mjs
70
72
  run "VP-client" node dev/vp-client-check.mjs
71
73
  run "VP-sync" node --test dev/vp-sync-check.mjs
72
74
  run "migration" node --test dev/workspace-migration-check.mjs
@@ -209,19 +209,50 @@ test('first-use focus has a complete refusal shape and targets reject unknown ca
209
209
  } finally { rmSync(dir, { recursive: true, force: true }) }
210
210
  })
211
211
 
212
- test('target route rejects an unrelated progress node but allows a course-bound route', () => {
212
+ test('target route allows a fourth-ranked target capability without imposing the focus ranking', () => {
213
213
  const { dir, store } = fixture()
214
214
  try {
215
+ assert.equal(store.target('set', {
216
+ expectedRevision: 0, contest: '西安区域赛', date: '2026-10-18', result: '区域赛金牌',
217
+ weeklyHours: 35, priorities: [
218
+ { id: 'contest-reading', weight: 100 },
219
+ { id: 'contest-decision', weight: 90 },
220
+ { id: 'dp-modeling', weight: 80 },
221
+ { id: 'graph-modeling', weight: 10 },
222
+ ],
223
+ }).ok, true)
224
+ assert.deepEqual(store.focus().postponed, ['graph-modeling'])
225
+ const route = store.route('最短路', { purpose: 'progress', courseBound: false })
226
+ assert.equal(route.ok, true)
227
+ assert.equal(route.targetAligned, true)
228
+ assert.deepEqual(route.competencyIds, ['graph-modeling'])
229
+ } finally { rmSync(dir, { recursive: true, force: true }) }
230
+ })
231
+
232
+ test('target route reports alignment without rejecting the coach choice', () => {
233
+ const { dir, store } = fixture()
234
+ try {
235
+ assert.equal(store.route('树状数组').targetAligned, false, 'no target is not an alignment claim')
215
236
  store.target('set', {
216
237
  expectedRevision: 0, contest: '西安区域赛', date: '2026-10-18', result: '区域赛金牌',
217
238
  weeklyHours: 35, priorities: [{ id: 'dp-modeling', weight: 100 }],
218
239
  })
219
- const unrelated = store.route('树状数组', { purpose: 'progress', courseBound: false })
220
- assert.equal(unrelated.ok, false)
221
- assert.match(unrelated.reject, /dp-modeling|当前重点/)
222
- const courseBound = store.route('树状数组', { purpose: 'progress', courseBound: true })
223
- assert.equal(courseBound.ok, true)
224
- assert.equal(courseBound.courseBound, true)
240
+ for (const options of [
241
+ { purpose: 'progress', courseBound: false },
242
+ { purpose: 'progress', courseBound: true },
243
+ { purpose: 'remediation', courseBound: false },
244
+ { purpose: 'review', courseBound: false },
245
+ ]) {
246
+ const unrelated = store.route('树状数组', options)
247
+ assert.equal(unrelated.ok, true)
248
+ assert.equal(unrelated.reject, '')
249
+ assert.equal(unrelated.targetAligned, false)
250
+ assert.deepEqual(unrelated.competencyIds, [])
251
+ assert.equal(unrelated.courseBound, options.courseBound)
252
+ }
253
+ const alignedReview = store.route('动态规划基础', { purpose: 'review', courseBound: true })
254
+ assert.equal(alignedReview.targetAligned, true)
255
+ assert.deepEqual(alignedReview.competencyIds, ['dp-modeling'])
225
256
  } finally { rmSync(dir, { recursive: true, force: true }) }
226
257
  })
227
258
 
@@ -313,5 +344,8 @@ test('corrupt stored strategy data returns a structured refusal', () => {
313
344
  const result = store.target('read')
314
345
  assert.equal(result.ok, false)
315
346
  assert.match(result.reject, /TARGET.yaml/)
347
+ const route = store.route('最短路')
348
+ assert.equal(route.ok, false)
349
+ assert.match(route.reject, /TARGET.yaml/)
316
350
  } finally { rmSync(dir, { recursive: true, force: true }) }
317
351
  })