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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "acmer-coach",
3
- "description": "小鲸教练插件:把「位置 + 判断」从小鲸的转述里拿回来。读 MAP/DEPENDS/PROGRESS,给唯一动作、判因、检测。热插拔,删掉即还原。",
4
- "version": "2.4.0",
3
+ "description": "小鲸教练插件:保存训练位置和真实证据,支持教练判断、持续教学与比赛备赛。读 MAP/DEPENDS/PROGRESS,提供训练、判因和检测工具。",
4
+ "version": "2.5.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/Miantiao1231/acmer-coach.git"
@@ -23,7 +23,7 @@
23
23
  "@deepseek-ai/dsh-tools": "0.2.0-rc.2"
24
24
  },
25
25
  "scripts": {
26
- "test": "node --test dev/curriculum-check.mjs dev/curriculum-evidence-check.mjs dev/strategy-check.mjs dev/catalog-check.mjs dev/coaching-contract-check.mjs dev/workspace-migration-check.mjs dev/vp-client-check.mjs dev/vp-sync-check.mjs"
26
+ "test": "node --test dev/curriculum-check.mjs dev/curriculum-evidence-check.mjs dev/strategy-check.mjs dev/catalog-check.mjs dev/coaching-contract-check.mjs dev/teaching-check.mjs dev/workspace-migration-check.mjs dev/vp-client-check.mjs dev/vp-sync-check.mjs"
27
27
  },
28
28
  "dsh": {
29
29
  "engines": {
package/verify-shape.mjs CHANGED
@@ -17,6 +17,7 @@
17
17
  // 退出码非 0 = 有错配。
18
18
 
19
19
  import { mkdtempSync, writeFileSync } from 'node:fs'
20
+ import assert from 'node:assert/strict'
20
21
  import os from 'node:os'
21
22
  import path from 'node:path'
22
23
  import { pathToFileURL } from 'node:url'
@@ -136,7 +137,7 @@ function validate(schema, value, at, errs) {
136
137
  if (!(k in props)) errs.push(`${at}.${k}: schema 未声明(additionalProperties:false → dsh 校验会拒)`)
137
138
  }
138
139
  for (const [k, s] of Object.entries(props)) {
139
- if (!(k in value)) { if (s.required) errs.push(`${at}.${k}: 缺 required 字段`); continue }
140
+ if (!(k in value)) { if (s.required === true || (Array.isArray(schema.required) && schema.required.includes(k))) errs.push(`${at}.${k}: 缺 required 字段`); continue }
140
141
  validate(s, value[k], `${at}.${k}`, errs)
141
142
  }
142
143
  } else if (t === 'array') {
@@ -153,6 +154,21 @@ function validate(schema, value, at, errs) {
153
154
  }
154
155
  }
155
156
 
157
+ // 嵌套对象的 required 列表描述子字段,不代表这个对象自身必填。
158
+ const optionalObjectSchema = { type: 'object', required: ['summary'], properties: {
159
+ summary: { type: 'string' },
160
+ studentEvidence: { type: 'object', required: ['quote'], properties: { quote: { type: 'string' } } },
161
+ } }
162
+ const validOptional = [], missingParent = [], missingChild = [], missingDsl = []
163
+ validate(optionalObjectSchema, { summary: '教练判断' }, 'out', validOptional)
164
+ validate(optionalObjectSchema, {}, 'out', missingParent)
165
+ validate(optionalObjectSchema, { summary: '教练判断', studentEvidence: {} }, 'out', missingChild)
166
+ validate({ type: 'object', properties: { summary: { type: 'string', required: true } } }, {}, 'out', missingDsl)
167
+ assert.deepEqual(validOptional, [])
168
+ assert.deepEqual(missingParent, ['out.summary: 缺 required 字段'])
169
+ assert.deepEqual(missingChild, ['out.studentEvidence.quote: 缺 required 字段'])
170
+ assert.deepEqual(missingDsl, ['out.summary: 缺 required 字段'])
171
+
156
172
  const mod = await import(`${INSTALLED}?sweep=${Date.now()}`)
157
173
  const registered = []
158
174
  mod.apply(mockCtx(registered))
@@ -169,10 +185,10 @@ const cases = [
169
185
  ['coach_ping', {}],
170
186
  ['coach_target', { action: 'read' }],
171
187
  ['coach_target', { action: 'set', expectedRevision: 0, contest: '测试区域赛', date: '2026-10-18', result: '金牌', teamMode: 'team', weeklyHours: 20, priorities: [{ id: 'contest-reading', weight: 80, reason: '测试目标' }] }],
172
- // 真正走一遍「目标 → 动作」硬闸:不属于当前能力的 progress 节点必须拒绝,
188
+ // 目标能力提供关联提示;没有映射的节点仍可由教练解释后安排,
173
189
  // 再切回无地图能力的读题卡,保留后续旧夹具的成功路径。
174
190
  ['coach_target', { action: 'set', expectedRevision: 1, contest: '测试区域赛', date: '2026-10-18', result: '金牌', teamMode: 'team', weeklyHours: 20, priorities: [{ id: 'dp-modeling', weight: 80, reason: '测试硬闸' }] }],
175
- ['coach_assign#target-gate', { node: 'B', deliverable: '把代码贴给我', why: '测试目标硬闸', minutes: 60, teachMinutes: 25 }],
191
+ ['coach_assign#target-advisory', { node: 'B', deliverable: '把代码贴给我', why: '测试未映射节点仍可训练', minutes: 60, teachMinutes: 25 }],
176
192
  ['coach_target', { action: 'set', expectedRevision: 2, contest: '测试区域赛', date: '2026-10-18', result: '金牌', teamMode: 'team', weeklyHours: 20, priorities: [{ id: 'contest-reading', weight: 80, reason: '测试目标' }] }],
177
193
  ['coach_vp_import', { events: [{ eventId: 'shape-vp', contest: '测试 VP', date: today, durationMinutes: 300, problems: [{ problemId: 'A', status: 'AC', competencies: ['contest-reading'] }] }] }],
178
194
  ['coach_vp_connect', { action: 'status' }],
@@ -206,6 +222,12 @@ const cases = [
206
222
  ['coach_next', { cursor: '并不存在' }], // 打错字
207
223
  ['coach_next', {}], // 游标空 → 读夹具
208
224
  ['coach_status', {}],
225
+ ['coach_note#read', { action: 'read', node: 'B' }],
226
+ ['coach_note#write', { action: 'write', node: 'B', summary: '已说明状态需要保留哪些历史;还没收到学生回应。', nextStep: '接着核对能否区分两段未来不同的历史。' }],
227
+ ['coach_note#read-saved', { action: 'read', node: 'B', limit: 5 }],
228
+ ['coach_note#quote-rejected', { action: 'write', node: 'B', summary: '待核实回应', nextStep: '核对原话', studentQuote: '我理解了这个状态的定义。' }],
229
+ ['coach_note#invalid-node', { action: 'write', node: 'ZZZ', summary: '测试笔记', nextStep: '继续' }],
230
+ ['coach_note#empty-summary', { action: 'write', node: 'B', summary: '', nextStep: '继续' }],
209
231
  ['coach_set_cursor', { node: 'B' }],
210
232
  ['coach_set_cursor', { node: 'ZZZ' }], // 拒
211
233
  ['coach_mark', { node: 'C', status: 'studying' }],
@@ -307,11 +329,19 @@ for (const [name, args, preset] of cases) {
307
329
  bad++
308
330
  continue
309
331
  }
310
- if (name === 'coach_assign#target-gate' && out.accepted !== false) {
311
- console.log(` FAIL ${name} → 目标硬闸没有拒绝无关 progress 节点`)
332
+ if (name === 'coach_assign#target-advisory' && (out.accepted !== true || out.targetAligned !== false)) {
333
+ console.log(` FAIL ${name} → 未映射节点应允许布置,并明确 targetAligned=false`)
312
334
  bad++
313
335
  continue
314
336
  }
337
+ if (name.startsWith('coach_note#')) {
338
+ const rejected = ['quote-rejected', 'invalid-node', 'empty-summary'].includes(name.split('#')[1])
339
+ if (out.ok === rejected || (!rejected && name !== 'coach_note#read' && out.notes?.length !== 1)) {
340
+ console.log(` FAIL ${name} → 续课笔记的成功/拒绝或落盘结果不符`)
341
+ bad++
342
+ continue
343
+ }
344
+ }
315
345
  const errs = []
316
346
  validate(tool.output.schema, out, 'out', errs)
317
347
  let renderNote = ''
package/verify.mjs CHANGED
@@ -89,7 +89,7 @@ for (const f of readdirSync(KNOWLEDGE)) {
89
89
  }
90
90
  process.env.COACH_DATA_DIR = tmpEarly
91
91
  let stale = []
92
- for (const f of ['index.js', 'package.json', 'lib/setup.js', 'lib/vp-client.js', 'lib/workspace-migration.js', 'lib/strategy.js', 'lib/curriculum.js', 'lib/curriculum-evidence.js', 'lib/curriculum-planning.js', 'assets/knowledge/NODE_SCOPES.yaml', 'assets/knowledge/CONTEST_PROFILES.yaml', 'assets/rules/coach-rules.md']) {
92
+ for (const f of ['index.js', 'package.json', 'lib/setup.js', 'lib/vp-client.js', 'lib/workspace-migration.js', 'lib/strategy.js', 'lib/teaching.js', 'lib/curriculum.js', 'lib/curriculum-evidence.js', 'lib/curriculum-planning.js', 'assets/knowledge/NODE_SCOPES.yaml', 'assets/knowledge/CONTEST_PROFILES.yaml', 'assets/rules/coach-rules.md']) {
93
93
  let same = true
94
94
  try {
95
95
  same = readFileSync(`${SOURCE_DIR}\\${f}`, 'utf8') === readFileSync(`${INSTALLED_DIR}\\${f}`, 'utf8')
@@ -898,9 +898,9 @@ check('标「在学」建立教学记录(这是"讲过"的机械痕迹)', ma
898
898
 
899
899
  // ③ 正面:标过之后同一行要报出「最早的教学痕迹」
900
900
  const stYes = await T5('coach_status').execute({}, undefined)
901
- check('★ 标过之后:报出最早的教学痕迹 + render 看得见「讲于 X」(验收 ③ 正面)',
901
+ check('★ 标过之后:报出最早接触记录,并说明不证明理解(验收 ③ 正面)',
902
902
  /^\d{4}-\d{2}-\d{2}$/.test(stYes.cursorTaught) &&
903
- T5('coach_status').output.render({}, stYes)[0].text.includes(`讲于 ${stYes.cursorTaught}`),
903
+ T5('coach_status').output.render({}, stYes)[0].text.includes(`接触记录 ${stYes.cursorTaught}(不证明理解)`),
904
904
  `cursorTaught=${JSON.stringify(stYes.cursorTaught)}`)
905
905
  // 游标还原 —— 下面几关的夹具是照着"游标本来就是空的"搭的
906
906
  if (cursorWas) await T5('coach_set_cursor').execute({ node: cursorWas }, undefined)
@@ -2362,7 +2362,7 @@ function shapeDiff(tool, out) {
2362
2362
  if (schema.additionalProperties === false && !(k in props)) extra.push(child(k))
2363
2363
  }
2364
2364
  for (const [k, s] of Object.entries(props)) {
2365
- if (s?.required && !(k in value)) missing.push(child(k))
2365
+ if ((s?.required === true || (Array.isArray(schema.required) && schema.required.includes(k))) && !(k in value)) missing.push(child(k))
2366
2366
  else if (k in value) {
2367
2367
  const d = walk(s, value[k], child(k))
2368
2368
  extra.push(...d.extra); missing.push(...d.missing)
@@ -2378,6 +2378,19 @@ function shapeDiff(tool, out) {
2378
2378
  }
2379
2379
  return walk(tool.output.schema, out ?? {}, '')
2380
2380
  }
2381
+ const optionalObjectSchema = { type: 'object', required: ['summary'], properties: {
2382
+ summary: { type: 'string' },
2383
+ studentEvidence: { type: 'object', required: ['quote'], properties: { quote: { type: 'string' } } },
2384
+ } }
2385
+ const schemaTool = { output: { schema: optionalObjectSchema } }
2386
+ check('★ 形状尺:可选对象缺省合法,子字段 required 不代表父对象必填',
2387
+ shapeDiff(schemaTool, { summary: '教练判断' }).missing.length === 0)
2388
+ check('★ 形状尺:父对象 required 列表中的字段缺省仍拒绝',
2389
+ shapeDiff(schemaTool, {}).missing.join(',') === 'summary')
2390
+ check('★ 形状尺:提供可选对象后,仍核对子字段 required',
2391
+ shapeDiff(schemaTool, { summary: '教练判断', studentEvidence: {} }).missing.join(',') === 'studentEvidence.quote')
2392
+ check('★ 形状尺:原 DSL 的 required=true 仍核对',
2393
+ shapeDiff({ output: { schema: { type: 'object', properties: { summary: { type: 'string', required: true } } } } }, {}).missing.join(',') === 'summary')
2381
2394
  const t1 = await planT.execute({ date: D[3], blocks: [{ from: '10:00', kind: 'review', node: 'A' }] })
2382
2395
  check('工具层:排得进去', t1.ok === true && t1.count === 1, t1.reject)
2383
2396
  const sd1 = shapeDiff(planT, t1)
@@ -3120,9 +3133,9 @@ check('studying 的用 at 算天数(3 天前标的)', rB33?.days === 3, `${r
3120
3133
  const txt33 = T33.get('coach_schedule').output.render({}, sch33)[0].text
3121
3134
  check('★ 渲染里摆得出来,且标明"该不该动是你判"',
3122
3135
  txt33.includes('火候') && txt33.includes('该不该动是你判'), (txt33.match(/.*火候.*/) ?? [''])[0])
3123
- check('★ 排课提醒里带着「复习别把新知识挤没」',
3124
- txt33.includes('复习别把新知识挤没了') && txt33.includes('留给推进'),
3125
- (txt33.match(/.*复习别把.*/) ?? ['(没有)'])[0].slice(0, 50))
3136
+ check('★ 排课建议依据目标与表现,不强制持续开新课',
3137
+ txt33.includes('根据目标') && txt33.includes('实际表现') && txt33.includes('迁移或 VP') && !txt33.includes('剩下的时间留给推进'),
3138
+ (txt33.match(/.*根据目标.*/) ?? ['(没有)'])[0].slice(0, 70))
3126
3139
 
3127
3140
  // 没有火候(全是未学/没有记录)时不该硬塞一段
3128
3141
  const tmp33b = mkdtempSync(path.join(os.tmpdir(), 'coach-ready-b-'))
@@ -3495,7 +3508,7 @@ mod38.apply(mockCtx(reg38))
3495
3508
  const T38 = (n) => reg38.find((t) => t.name === n)
3496
3509
  const asg38 = T38('coach_assign')
3497
3510
  const prog38 = () => readFileSync(path.join(tmp38, 'PROGRESS.yaml'), 'utf8')
3498
- // 夹具里的游标是空的 —— 先设上。(`coach_status` 报「讲于哪」读的是
3511
+ // 夹具里的游标是空的 —— 先设上。(`coach_status` 报接触日期读的是
3499
3512
  // PROGRESS.yaml 里的游标,不是 assign 那条路径传进来的 cursor 参数。)
3500
3513
  await T38('coach_set_cursor').execute({ node: '记忆化搜索' })
3501
3514
 
@@ -3504,22 +3517,28 @@ const g38a = await asg38.execute({ cursor: '记忆化搜索', node: '记忆化
3504
3517
  check('★ 游标节点 + 没讲过 + 不给 teachMinutes → 拒(这就是那个洞)',
3505
3518
  g38a.accepted === false && g38a.reject.includes('全新知识点'), g38a.reject.split('\n')[0])
3506
3519
 
3507
- // ② 带讲授段 → 放行,而且**留下教学痕迹**
3520
+ // ② 带讲授计划 → 放行,但计划不能伪装成已发生的教学。
3508
3521
  const g38b = await asg38.execute({
3509
3522
  cursor: '记忆化搜索', node: '记忆化搜索', deliverable: '贴代码', minutes: 40, teachMinutes: 20 })
3510
3523
  check('★ 带 teachMinutes → 放行', g38b.accepted === true, g38b.reject)
3511
- check('★ 讲授段落盘:节点上记下 `taught`',
3512
- prog38().includes('taught:'), (prog38().match(/.*taught.*/) ?? ['(没有)'])[0].trim())
3524
+ check('★ 讲授计划不写 taught:计划不等于实际接触',
3525
+ !prog38().includes('taught:'), prog38())
3513
3526
  const st38 = await T38('coach_status').execute({})
3514
- check('★ coach_status 报得出「讲于」那天(以前永远是"还没有教学记录")',
3515
- st38.cursorTaught !== '', `cursorTaught=${st38.cursorTaught || '(空)'}`)
3516
- check('★ 记讲授**不动 status**(讲了 ≠ 学会了 —— 自评/检测才是那两级)',
3527
+ check('★ 只有计划时 cursorTaught 仍为空,计划时长另存在待办里',
3528
+ st38.cursorTaught === '' && st38.pendingActions.find((a) => a.node === '记忆化搜索')?.teachMinutes === 20,
3529
+ `cursorTaught=${st38.cursorTaught || '(空)'}`)
3530
+ check('★ 讲授计划不动 status(还没发生的课不能变成学习记录)',
3517
3531
  st38.studyingCount === 0 && st38.learnedCount === 0 && st38.verifiedCount === 0,
3518
3532
  JSON.stringify({ 在学: st38.studyingCount, 学过: st38.learnedCount, 已验证: st38.verifiedCount }))
3519
3533
 
3520
- // ③ 讲过了 → 同一个游标节点再布置,不用重讲(豁免还在,只是有了条件)
3534
+ // ③ 计划不能解锁继续练;教练记录实际进入学习后才适用游标例外。
3521
3535
  const g38c = await asg38.execute({ cursor: '记忆化搜索', node: '记忆化搜索', deliverable: '贴代码', minutes: 40 })
3522
- check('★ 讲过之后:游标节点再布置**不用**重讲(豁免仍然生效)', g38c.accepted === true, g38c.reject)
3536
+ check('★ 只有计划:不给 teachMinutes 仍拒绝',
3537
+ g38c.accepted === false && g38c.reject.includes('全新知识点'), g38c.reject)
3538
+ const marked38 = await T38('coach_mark').execute({ node: '记忆化搜索', status: 'studying' })
3539
+ check('★ 实际开始学习由教练显式记录', marked38.ok === true, marked38.reject)
3540
+ const g38real = await asg38.execute({ cursor: '记忆化搜索', node: '记忆化搜索', deliverable: '贴代码', minutes: 40 })
3541
+ check('★ 实际接触之后:游标节点再布置不用重讲', g38real.accepted === true, g38real.reject)
3523
3542
 
3524
3543
  // ④ 不误伤:非游标的新节点照样要讲
3525
3544
  const g38d = await asg38.execute({ cursor: '记忆化搜索', node: '区间 DP', deliverable: '贴代码', minutes: 40 })