acmer-coach 2.4.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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')
@@ -364,8 +364,8 @@ await denyCase('挑了个不是下一层的节点 → 拒绝',
364
364
  // 而训练员已经把位运算标成已验证 —— 它不再是个"缺前置"的场景了。
365
365
  // **别在会变的真实进度上写死假设。** 等价用例(更严)在第 24 节的合成夹具里。
366
366
  // ④ 时间盒太短
367
- await denyCase('净时间低于 30 分钟 → 拒绝',
368
- { cursor: '动态规划基础', node: '区间 DP', deliverable: '贴代码', minutes: 15 }, '太短')
367
+ const shortDiagnostic = await assign.execute({ cursor: '动态规划基础', node: '区间 DP', kind: 'diagnostic', deliverable: '解释模型选择', minutes: 15 })
368
+ check('15 分钟诊断可直接布置,不伪造讲课或要求先学完', shortDiagnostic.accepted && shortDiagnostic.totalMinutes === 15, shortDiagnostic.reject)
369
369
  // ⑤ 没交付物
370
370
  await denyCase('没写交付物 → 拒绝',
371
371
  { cursor: '动态规划基础', node: '区间 DP', deliverable: '' }, '交付物')
@@ -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)
@@ -987,9 +987,9 @@ const progPath5 = () => readFileSync(prog5, 'utf8')
987
987
  // 卷子还在(第 20 关开的):P1 易 / P4 中 / P6 难,限时 90,通过线 4
988
988
  const g1 = await grade.execute({
989
989
  results: [
990
- { problemId: 'P1', solved: true, minutes: 15 },
991
- { problemId: 'P4', solved: true, minutes: 25 },
992
- { problemId: 'P6', solved: true, minutes: 30 },
990
+ { problemId: 'P1', solved: true, minutes: 15, independent: true },
991
+ { problemId: 'P4', solved: true, minutes: 25, independent: true },
992
+ { problemId: 'P6', solved: true, minutes: 30, independent: true },
993
993
  ],
994
994
  }, undefined)
995
995
  check('判卷成功', g1.ok === true, g1.reject)
@@ -997,7 +997,7 @@ check('三题全下 → 6 分', g1.score === 6, String(g1.score))
997
997
  check('用时 70 ≤ 90 → 过', g1.outcome === 'passed', g1.outcome)
998
998
  const after1 = progPath5()
999
999
  check('★ 写过检测记录(checks)', after1.includes('checks'), after1.includes('checks') ? '' : '没写')
1000
- check('★ 过了就把状态标成 verified(这就是 verified 的定义)',
1000
+ check('★ 明确独立且过卷才新增 verified',
1001
1001
  after1.includes('status: verified'), after1.includes('status') ? '' : '没写 status')
1002
1002
  // 注意别拿**文件头注释**当数据测:头里就写着 "pending 是正押着的那张检测卷",
1003
1003
  // 用 includes('pending') 永远为真、永远测不出东西。要盯的是 YAML 顶层键。
@@ -1012,9 +1012,9 @@ const denyG = async (label, args, want) => {
1012
1012
  check(label, ok, ok ? '' : `ok=${r.ok} reject="${r.reject}"`)
1013
1013
  }
1014
1014
  const r3 = [
1015
- { problemId: 'P1', solved: true, minutes: 10 },
1016
- { problemId: 'P4', solved: true, minutes: 10 },
1017
- { problemId: 'P6', solved: true, minutes: 10 },
1015
+ { problemId: 'P1', solved: true, minutes: 10, independent: true },
1016
+ { problemId: 'P4', solved: true, minutes: 10, independent: true },
1017
+ { problemId: 'P6', solved: true, minutes: 10, independent: true },
1018
1018
  ]
1019
1019
  await denyG('手里没押卷就判卷 → 拒绝', { results: r3 }, '没押着')
1020
1020
 
@@ -1032,13 +1032,13 @@ await test.execute({ node: '线段树', problems: [
1032
1032
  ] }, undefined)
1033
1033
  await denyG('结果少给一道 → 拒绝', { results: r3.slice(0, 2) }, '一道都不能少')
1034
1034
  await denyG('报了个不在卷子里的题号 → 拒绝',
1035
- { results: [...r3.slice(0, 2), { problemId: 'P99999', solved: true, minutes: 5 }] }, '卷子里没有')
1035
+ { results: [...r3.slice(0, 2), { problemId: 'P99999', solved: true, minutes: 5, independent: true }] }, '卷子里没有')
1036
1036
 
1037
1037
  const g2 = await grade.execute({
1038
1038
  results: [
1039
- { problemId: 'P2', solved: true, minutes: 20 },
1040
- { problemId: 'P5', solved: true, minutes: 30 },
1041
- { problemId: 'P7', solved: true, minutes: 50 }, // 合计 100 > 90
1039
+ { problemId: 'P2', solved: true, minutes: 20, independent: true },
1040
+ { problemId: 'P5', solved: true, minutes: 30, independent: true },
1041
+ { problemId: 'P7', solved: true, minutes: 50, independent: true }, // 合计 100 > 90
1042
1042
  ],
1043
1043
  }, undefined)
1044
1044
  check('★ 分数够但超时 → 判「不熟」,不算过', g2.outcome === 'overTime', g2.outcome)
@@ -1053,7 +1053,7 @@ await test.execute({ node: '线段树', problems: [
1053
1053
  ] }, undefined)
1054
1054
  const g3 = await grade.execute({
1055
1055
  results: [
1056
- { problemId: 'P1', solved: true, minutes: 18 },
1056
+ { problemId: 'P1', solved: true, minutes: 18, independent: true },
1057
1057
  { problemId: 'P4', solved: false, minutes: 30 },
1058
1058
  { problemId: 'P6', solved: false, minutes: 20 },
1059
1059
  ],
@@ -1108,9 +1108,9 @@ check('★ 卷面写明「网上找的 · 核实:…」(他要能复核)',
1108
1108
 
1109
1109
  const gWebR = await grade.execute({
1110
1110
  results: [
1111
- { problemId: 'ABC300-F', solved: true, minutes: 15 },
1112
- { problemId: 'P1', solved: true, minutes: 20 },
1113
- { problemId: 'P4', solved: true, minutes: 30 },
1111
+ { problemId: 'ABC300-F', solved: true, minutes: 15, independent: true },
1112
+ { problemId: 'P1', solved: true, minutes: 20, independent: true },
1113
+ { problemId: 'P4', solved: true, minutes: 30, independent: true },
1114
1114
  ],
1115
1115
  }, undefined)
1116
1116
  check('★ 全下 → 过', gWebR.ok === true && gWebR.outcome === 'passed', `${gWebR.outcome} ${gWebR.reject}`)
@@ -1168,9 +1168,9 @@ check('★ 新核实的 verifiedReused=false(这次真核了,不是沿用)
1168
1168
  // 判掉这张卷,才能开第二张(一次只开一张)
1169
1169
  const gFirst = await grade.execute({
1170
1170
  results: [
1171
- { problemId: 'P1', solved: true, minutes: 15 },
1172
- { problemId: 'P4', solved: true, minutes: 25 },
1173
- { problemId: 'P6', solved: true, minutes: 35 },
1171
+ { problemId: 'P1', solved: true, minutes: 15, independent: true },
1172
+ { problemId: 'P4', solved: true, minutes: 25, independent: true },
1173
+ { problemId: 'P6', solved: true, minutes: 35, independent: true },
1174
1174
  ],
1175
1175
  }, undefined)
1176
1176
  check('夹具:第一张卷判掉了', gFirst.ok === true, gFirst.reject)
@@ -1187,9 +1187,9 @@ check('★ 卷面上写明是沿用台账记录',
1187
1187
  (test.output.render({}, reuse)[0].text.match(/.*沿用台账.*/) ?? ['(没有)'])[0].slice(0, 60))
1188
1188
  const gSecond = await grade.execute({
1189
1189
  results: [
1190
- { problemId: 'P1', solved: true, minutes: 15 },
1191
- { problemId: 'P4', solved: true, minutes: 25 },
1192
- { problemId: 'P6', solved: true, minutes: 35 },
1190
+ { problemId: 'P1', solved: true, minutes: 15, independent: true },
1191
+ { problemId: 'P4', solved: true, minutes: 25, independent: true },
1192
+ { problemId: 'P6', solved: true, minutes: 35, independent: true },
1193
1193
  ],
1194
1194
  }, undefined)
1195
1195
  check('夹具:第二张卷也判掉了(不留 pending 给后面几节)', gSecond.ok === true, gSecond.reject)
@@ -1296,9 +1296,9 @@ check('★ 并且逐道标出是沿用的(三道都要,不能只认一道)
1296
1296
  // "上一张没判"被拒,看着像通过,其实拒的理由跟本关要验的完全无关。
1297
1297
  const gClos = await grade.execute({
1298
1298
  results: [
1299
- { problemId: 'P1', solved: true, minutes: 15 },
1300
- { problemId: 'P4', solved: true, minutes: 25 },
1301
- { problemId: 'P6', solved: true, minutes: 35 },
1299
+ { problemId: 'P1', solved: true, minutes: 15, independent: true },
1300
+ { problemId: 'P4', solved: true, minutes: 25, independent: true },
1301
+ { problemId: 'P6', solved: true, minutes: 35, independent: true },
1302
1302
  ],
1303
1303
  }, undefined)
1304
1304
  check('夹具:闭环那张卷判掉了(不留 pending)', gClos.ok === true, gClos.reject)
@@ -1371,9 +1371,9 @@ const open22 = await test.execute({ node: '线段树', problems: [
1371
1371
  check('夹具:22b 那张卷开出来了', open22.ok === true, open22.reject)
1372
1372
  const g22 = await grade.execute({
1373
1373
  results: [
1374
- { problemId: 'P1', solved: true, minutes: 12 },
1375
- { problemId: 'P4', solved: true, minutes: 22 },
1376
- { problemId: 'P6', solved: true, minutes: 35 },
1374
+ { problemId: 'P1', solved: true, minutes: 12, independent: true },
1375
+ { problemId: 'P4', solved: true, minutes: 22, independent: true },
1376
+ { problemId: 'P6', solved: true, minutes: 35, independent: true },
1377
1377
  ],
1378
1378
  }, undefined)
1379
1379
  check('全下 → 过', g22.outcome === 'passed', g22.outcome)
@@ -1392,7 +1392,7 @@ await test.execute({ node: '线段树', problems: [
1392
1392
  ] }, undefined)
1393
1393
  const g22b = await grade.execute({
1394
1394
  results: [
1395
- { problemId: 'P2', solved: true, minutes: 10 },
1395
+ { problemId: 'P2', solved: true, minutes: 10, independent: true },
1396
1396
  { problemId: 'P5', solved: false, minutes: 30 },
1397
1397
  { problemId: 'P7', solved: false, minutes: 30 },
1398
1398
  ],
@@ -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)
@@ -2651,9 +2664,9 @@ check('★ 并且给出了撤卷的出口(不然模型只能编成绩)',
2651
2664
  // 原先传空串 = 不归一 → 卷子存 `NC15121`、小鲸报 `15121` 就撞不上,
2652
2665
  // 判成「卷子里没有这条结果」。判卷是闭环最后一步,错在这整张卷就没了。
2653
2666
  const g30 = await T30.get('coach_grade').execute({ results: [
2654
- { problemId: '15121', solved: true, minutes: 15 }, // ← 裸号
2655
- { problemId: '1015D', solved: true, minutes: 25 },
2656
- { problemId: '1016E', solved: true, minutes: 35 },
2667
+ { problemId: '15121', solved: true, minutes: 15, independent: true }, // ← 裸号
2668
+ { problemId: '1015D', solved: true, minutes: 25, independent: true },
2669
+ { problemId: '1016E', solved: true, minutes: 35, independent: true },
2657
2670
  ]})
2658
2671
  check('★ 牛客裸号能对上卷子里的 NC15121', g30.ok === true, JSON.stringify(g30.reject))
2659
2672
  check('判完之后卷子清掉了', prog30.load().pending === null,
@@ -2679,9 +2692,9 @@ p30c.pending = { node: 'A', date: '2026-09-16', at: '2026-09-16 11:00:00',
2679
2692
  ] }
2680
2693
  prog30.save(p30c)
2681
2694
  const gw = await T30.get('coach_grade').execute({ results: [
2682
- { problemId: '1015D', solved: true, minutes: 10 },
2683
- { problemId: '1016E', solved: true, minutes: 20 },
2684
- { problemId: 'abc999_c', solved: true, minutes: 30 },
2695
+ { problemId: '1015D', solved: true, minutes: 10, independent: true },
2696
+ { problemId: '1016E', solved: true, minutes: 20, independent: true },
2697
+ { problemId: 'abc999_c', solved: true, minutes: 30, independent: true },
2685
2698
  ]})
2686
2699
  check('★ 判卷报出自述题数', gw.selfReported === 1, `${gw.selfReported}`)
2687
2700
  check('★ 自述题数落进 checks(事后翻记录分得出这张卷的成色)',
@@ -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-'))
@@ -3311,27 +3324,27 @@ check('★ 渲染把两个数都印出来(同一个 40 两个意思,就是
3311
3324
  check('★ 默认时不啰嗦(boxNote 空)', a35.boxNote === '' && !t35a.includes('止损点'), a35.boxNote)
3312
3325
 
3313
3326
  const a35b = await asg35.execute({ ...base35, minutes: 30 })
3314
- check('★ 净 30 → 整块 50(30+20),并提醒「你压的是止损点,不是块」',
3315
- a35b.accepted === true && a35b.totalMinutes === 65 && a35b.boxNote.includes('你压的是止损点'),
3327
+ check('★ 净 30 → 练习与收尾 50,加讲解共 65,报告可直接排的时长',
3328
+ a35b.accepted === true && a35b.totalMinutes === 65 && a35b.boxNote.includes('minutes=65'),
3316
3329
  a35b.boxNote)
3317
- check('★ 净 ≠ 40 时点出「表上排不出这个形状」(表上只有 60/25/90)',
3318
- a35b.boxNote.includes('排不出这个形状') && a35b.boxNote.includes('cycle 60 / review 25 / exam 90'),
3330
+ check('★ 自定时长可以如实排程,不再宣称只能 60/25/90',
3331
+ !a35b.boxNote.includes('排不出这个形状') && a35b.boxNote.includes('排程'),
3319
3332
  a35b.boxNote)
3320
3333
  const t35b = txt35(asg35, a35b)
3321
- check('★ 提醒印进人话里', t35b.includes('⚠️') && t35b.includes('止损点'),
3334
+ check('★ 自定时长印进人话里', t35b.includes('minutes=65'),
3322
3335
  (t35b.match(/.*⚠️.*/) ?? ['(没有)'])[0].trim())
3323
3336
 
3324
3337
  const a35c = await asg35.execute({ ...base35, minutes: 29 })
3325
- check('★ 净 29(低于下限 30)→ 拒,理由指路换更小的题 / review 块',
3326
- a35c.accepted === false && a35c.reject.includes('太短') && a35c.reject.includes('review'),
3338
+ check('★ 净 29 可接受,真实总时长 64 分钟',
3339
+ a35c.accepted === true && a35c.totalMinutes === 64,
3327
3340
  a35c.reject.split('\n')[0])
3328
- check('★ 被拒时形状也齐(新字段漏一个,理由就会被盖成校验错)',
3329
- a35c.minutes === 29 && a35c.tailMinutes === 20 && a35c.boxNote === '',
3341
+ check('★ 自定时长保存净用时与收尾,不丢说明',
3342
+ a35c.minutes === 29 && a35c.tailMinutes === 20 && a35c.boxNote.includes('minutes=64'),
3330
3343
  JSON.stringify({ 净: a35c.minutes, 收尾: a35c.tailMinutes, boxNote: a35c.boxNote }))
3331
3344
 
3332
3345
  const a35d = await asg35.execute({ ...base35, minutes: 60 })
3333
- check('★ 净 60 → 整块 80,同样提醒形状对不上',
3334
- a35d.accepted === true && a35d.totalMinutes === 95 && a35d.boxNote.includes('排不出这个形状'),
3346
+ check('★ 净 60 → 整块 80,加讲解共 95,可以直接排程',
3347
+ a35d.accepted === true && a35d.totalMinutes === 95 && a35d.boxNote.includes('minutes=95'),
3335
3348
  a35d.boxNote)
3336
3349
 
3337
3350
  // 账本(#6 的 pendingActions)里存的必须是**拆开的数**,口径才不会下次再漂
@@ -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 })