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/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // acmer-coach
2
2
  // ══════════════════════════════════════════════════════════════════
3
- // 小鲸教练插件。目标:把「位置 + 判断」从小鲸的转述里拿回来 ——
4
- // 给它唯一动作、让它读代码判因、按检测推进,而不是报一堆百分比。
3
+ // 小鲸教练插件。目标:保存训练位置与可核查的证据,
4
+ // 让教练据此决定讲法、练习和复习,避免跨会话丢进度。
5
5
  //
6
6
  // 注册走 bundle 层:package.json 声明 `dsh.bundle.patch`,由
7
7
  // cordis.patch.yml 里那行 insert 挂上去。
@@ -31,26 +31,27 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
31
31
  import { initDataDir, syncCodeforces, contactEvidence, importPool, importRecords } from './lib/setup.js'
32
32
  import { createCurriculumStore, curriculumTool } from './lib/curriculum.js'
33
33
  import { createStrategyStore } from './lib/strategy.js'
34
+ import { createTeachingNotes } from './lib/teaching.js'
34
35
  import { createVpClient } from './lib/vp-client.js'
35
36
  import { migratePersonalWorkspace as migrateWorkspacePersonalFiles } from './lib/workspace-migration.js'
36
37
 
37
38
  const name = 'acmer-coach'
38
39
  const inject = ['tools']
39
40
 
40
- const VERSION = '2.4.0'
41
+ const VERSION = '2.6.0'
41
42
 
42
- // 块结构(方针 §4.1.1):40 自己做 + 10 解决遗留 + 10 重写 = 60 分钟。
43
- // 这是**一个题目循环**的固定形状。时间不够该换更小的题,不是把块压扁。
43
+ // 默认题目循环:40 自己做 + 10 解决遗留 + 10 重写 = 60 分钟。
44
+ // 小诊断和完整 VP 使用独立时长,不强加题目循环的收尾。
44
45
  //
45
46
  // ⚠️ **单位统一(需求 #7)**:系统里说「40 分钟」,一律指
46
47
  // **他自己动手做题的净时间** —— 止损点就是它。收尾那 20 分钟(遗留 10 +
47
48
  // 重写 10)**固定**、不随净时间压缩。
48
- // 净 40(默认)→ 整块 60;净 30(下限)→ 整块 50
49
+ // 净 40(默认)→ 整块 60;显式小任务可缩短净时间
49
50
  // coach_assign 的参数曾经是"总时长"(工具内部减 20 才是自己做),而
50
51
  // coach_plan 的 cycle 和规则文件里的 40 都是"自己做" —— 同一个数两个意思,
51
52
  // 报时间线就会卡在这儿(传 40,实际只拿到 20 分钟自己做)。
52
53
  const NET_DEFAULT_MIN = 40 // 自己做:默认
53
- const NET_FLOOR_MIN = 30 // 自己做:下限。再短就该换更小的题,不是压扁循环
54
+ const NET_FLOOR_MIN = 1 // 实际时间盒由教练按任务决定
54
55
  const TAIL_MIN = 20 // 解决遗留(10) + 重写(10),不随净时间压缩
55
56
  const CYCLE_TOTAL_MIN = NET_DEFAULT_MIN + TAIL_MIN // 60:题目循环的整块长度(plan 的 cycle 就是它)
56
57
 
@@ -83,6 +84,7 @@ function createStores(vpDir) {
83
84
  })
84
85
  curriculum = createCurriculumStore({
85
86
  dataDir: DATA_DIR, loadMap, loadProgress, loadSchedule,
87
+ loadCompetencies: () => strategy.loadCompetencies(),
86
88
  loadStrategyEvidence: () => strategy.evidence().evidence,
87
89
  })
88
90
  }
@@ -467,12 +469,13 @@ function setScheduleField(date, field, value) {
467
469
  // 而排程本身是**教学判断**(先补哪个点、排多密、今天要不要减量),
468
470
  // 那正是教练存在的理由,不该退回给训练员。
469
471
  //
470
- // 三种块(方针 §4.1.2)。**时长是常量,调用方不能传** ——
471
- // 传了就会冒出「排个 37 分钟的循环块」,而 40/10/10 的止损点全靠这个时长守着。
472
+ // 块类型保留已有默认时长,允许教练按任务明确整块时长。
472
473
  const SCHED_KINDS = {
473
474
  cycle: { min: 60, name: '题目循环' },
474
475
  review: { min: 25, name: '复习一题' },
475
476
  exam: { min: 90, name: '章节检测' },
477
+ diagnostic: { min: 15, name: '短诊断' },
478
+ vp: { min: 300, name: '完整 VP' },
476
479
  }
477
480
 
478
481
  // 网格范围。⚠️ **必须和 skilltree_template.html 里的 HOUR_FROM / HOUR_TO 逐字一致**:
@@ -624,13 +627,18 @@ function planDay(date, blocks, opts = {}) {
624
627
  return
625
628
  }
626
629
  const node = String(b?.node ?? '').trim()
627
- if (!byId.has(node)) {
628
- const near = suggest(node, [...byId.values()])
629
- errs.push(`${no}:地图里没有「${node}」这个知识点,别自己造。` +
630
- (near.length ? `相近的有:${near.join(' / ')}` : '先用 coach_next 看他该学什么。'))
630
+ const competency = String(b?.competency ?? '').trim()
631
+ const identity = trainingIdentity(node, competency)
632
+ if (!identity.ok) {
633
+ errs.push(`${no}:${identity.reject}`)
631
634
  return
632
635
  }
633
- out.push({ a, z: a + spec.min, kind, node, no })
636
+ const minutes = b?.minutes === undefined ? spec.min : b.minutes
637
+ if (!Number.isInteger(minutes) || minutes <= 0 || minutes > 960) {
638
+ errs.push(`${no}:minutes 必须是 1–960 的整数分钟。`)
639
+ return
640
+ }
641
+ out.push({ a, z: a + minutes, kind, node, competency, no })
634
642
  })
635
643
 
636
644
  // 排到网格外面的块,页面画不出来
@@ -671,6 +679,7 @@ function planDay(date, blocks, opts = {}) {
671
679
 
672
680
  const planned = sorted.map((b) => ({
673
681
  from: minHM(b.a), to: minHM(b.z), kind: b.kind, node: b.node,
682
+ ...(b.competency ? { competency: b.competency } : {}),
674
683
  }))
675
684
  const w = setScheduleField(d, 'planned', planned)
676
685
  if (!w.ok) return w
@@ -680,8 +689,9 @@ function planDay(date, blocks, opts = {}) {
680
689
  planned: sorted.map((b) => ({
681
690
  from: minHM(b.a), to: minHM(b.z), minutes: b.z - b.a,
682
691
  kind: b.kind, kindName: SCHED_KINDS[b.kind].name,
683
- node: b.node, nodeName: byId.get(b.node)?.name ?? b.node,
684
- domain: byId.get(b.node)?.domain ?? '',
692
+ node: b.node, nodeName: trainingIdentity(b.node, b.competency).name ?? b.node,
693
+ domain: trainingIdentity(b.node, b.competency).domain ?? '',
694
+ ...(b.competency ? { competency: b.competency } : {}),
685
695
  })),
686
696
  // 只报「这天一共排了多少分钟」这个**事实**(他排完之后自己看表用)。
687
697
  planMin,
@@ -756,11 +766,12 @@ function unplanDay(date, opts = {}) {
756
766
  if (!w.ok) return w
757
767
 
758
768
  const { byId } = loadMap()
759
- const nameOf = (b) => byId.get(String(b?.node ?? ''))?.name ?? String(b?.node ?? '')
769
+ const nameOf = (b) => trainingIdentity(String(b?.node ?? ''), String(b?.competency ?? '')).name ?? String(b?.node ?? '')
760
770
  const pack = (b) => ({
761
771
  from: String(b?.from ?? ''), to: String(b?.to ?? ''),
762
772
  kind: String(b?.kind ?? ''), kindName: SCHED_KINDS[b?.kind]?.name ?? String(b?.kind ?? ''),
763
773
  node: String(b?.node ?? ''), nodeName: nameOf(b),
774
+ ...(b?.competency ? { competency: String(b.competency) } : {}),
764
775
  })
765
776
  return {
766
777
  ok: true, reject: '', date: d,
@@ -847,7 +858,8 @@ function scheduleOverview(days = SCHED_WINDOW, fromDate = '') {
847
858
  kind: String(b?.kind ?? ''),
848
859
  kindName: SCHED_KINDS[b?.kind]?.name ?? String(b?.kind ?? ''),
849
860
  node: String(b?.node ?? ''),
850
- nodeName: byId.get(String(b?.node ?? ''))?.name ?? String(b?.node ?? ''),
861
+ nodeName: trainingIdentity(String(b?.node ?? ''), String(b?.competency ?? '')).name ?? String(b?.node ?? ''),
862
+ ...(b?.competency ? { competency: String(b.competency) } : {}),
851
863
  }
852
864
  })
853
865
  const planMin = planned.reduce((t, b) => t + b.minutes, 0)
@@ -876,12 +888,13 @@ function scheduleOverview(days = SCHED_WINDOW, fromDate = '') {
876
888
  const e = {
877
889
  node: nodeId,
878
890
  plannedMin: planned
879
- .filter((b) => String(b.node) === nodeId)
891
+ .filter((b) => a?.competency ? b.competency === a.competency : String(b.node) === nodeId && !b.competency)
880
892
  .reduce((t, b) => t + Number(b.minutes ?? 0), 0),
881
893
  actualMin: Number(a?.actualMin ?? 0),
882
894
  solved: a?.solved === true,
883
895
  independent: a?.independent === true,
884
896
  }
897
+ if (a?.competency) e.competency = String(a.competency)
885
898
  if (a?.note) e.note = String(a.note)
886
899
  return e
887
900
  }),
@@ -906,6 +919,13 @@ function resolveCursor(explicit) {
906
919
  return s || loadProgress().cursor
907
920
  }
908
921
 
922
+ function trainingIdentity(node, competency) {
923
+ if (Boolean(node) === Boolean(competency)) return { ok: false, reject: 'node 与 competency 必须且只能指定一个。' }
924
+ const entry = competency ? strategy.loadCompetencies().find(item => item.id === competency) : loadMap().byId.get(node)
925
+ if (!entry) return { ok: false, reject: competency ? `能力目录中没有「${competency}」。` : `地图里没有「${node}」这个知识点,别自己造。先调 coach_next 看候选。` }
926
+ return { ok: true, key: node || `competency:${competency}`, name: entry.name ?? entry.title ?? node, domain: entry.domain ?? (competency ? '比赛能力' : '') }
927
+ }
928
+
909
929
  // 状态读取。**缺省即未学** —— 只有非未学的节点才会被写进文件。
910
930
  function nodeStatus(progress, id) {
911
931
  const s = progress?.nodes?.[id]?.status
@@ -993,7 +1013,7 @@ function nodeFlags(progress, byId) {
993
1013
  // 判据 = 进度文件里**有没有关于这个节点的记录**。缺省即未学(见 nodeStatus:
994
1014
  // 只有非未学的节点才会被写进文件),所以"记录存在"本身就说明有东西发生过:
995
1015
  // · `rec.at` —— 教练标过「在学」的那天(见 markStatus:只在状态真变时更新)
996
- // · `taught` —— 布置过讲授段的那天(coach_assign 带 teachMinutes)
1016
+ // · `taught` —— 旧版讲解计划留下的日期,保留兼容,不证明实际讲过
997
1017
  // · `passes` —— 他打过卡(coach_log)
998
1018
  // · `checks` —— 判过一次卷(coach_grade)
999
1019
  //
@@ -1008,9 +1028,7 @@ function taughtEvidence(progress, id) {
1008
1028
  if (!rec) return null
1009
1029
  const dates = [
1010
1030
  String(rec.at ?? ''),
1011
- // 讲授段的落点:在这之前,讲过的节点**不留任何痕迹** ——
1012
- // coach_status 会一直报「还没有教学记录」,而"游标节点豁免先讲闸门"这条
1013
- // 也就永远没法按"讲过没"来判断(这个漏洞就是从这儿反推出来的)。
1031
+ // 兼容旧版接触线索;新计划不再写 taught,续课笔记也不算掌握证据。
1014
1032
  ...(Array.isArray(rec.taught) ? rec.taught.map((t) => String(t ?? '')) : []),
1015
1033
  ...(Array.isArray(rec.passes) ? rec.passes.map((p) => String(p?.date ?? '')) : []),
1016
1034
  ...(Array.isArray(rec.checks) ? rec.checks.map((c) => String(c?.date ?? '')) : []),
@@ -1670,7 +1688,7 @@ function registerPages(ctx) {
1670
1688
  // 它要防的是"规则被长对话压掉",而规则在 AGENTS.md 里 —— 那是 system prompt,
1671
1689
  // 压根不参与压缩。真正会丢的是**位置**,而 pre-step 每轮都重注一遍,
1672
1690
  // 压掉的下一轮就回来了。所以那半条是多余的。
1673
- function registerStateHook(ctx) {
1691
+ function registerStateHook(ctx, teaching) {
1674
1692
  ctx.on('agent/pre-step', async ({ messages, step }, next) => {
1675
1693
  const decision = await next()
1676
1694
  try {
@@ -1684,13 +1702,14 @@ function registerStateHook(ctx) {
1684
1702
  const studying = Object.entries(p.nodes ?? {})
1685
1703
  .filter(([, r]) => r?.status === 'studying')
1686
1704
  .map(([id]) => id)
1705
+ const teachingContext = teaching.context(p)
1687
1706
 
1688
1707
  // 课程可以先于游标存在;即使尚未布置动作,也要在新会话继续同一路线。
1689
1708
  let hasCurriculum = false
1690
1709
  try { hasCurriculum = Boolean(curriculum.load()) } catch { hasCurriculum = true }
1691
1710
  let hasTarget = false
1692
1711
  try { hasTarget = Boolean(strategy.loadTarget().contest) } catch { hasTarget = true }
1693
- if (!cursor && !pending && !studying.length && !hasCurriculum && !hasTarget) return decision
1712
+ if (!cursor && !pending && !studying.length && !hasCurriculum && !hasTarget && !teachingContext) return decision
1694
1713
 
1695
1714
  const { byId } = loadMap()
1696
1715
  const nameOf = (id) => byId.get(String(id))?.name ?? String(id)
@@ -1698,6 +1717,7 @@ function registerStateHook(ctx) {
1698
1717
  const lines = ['<coach_state>', '(教练插件注入的位置,不用再调工具问)']
1699
1718
  lines.push(strategy.context())
1700
1719
  lines.push(curriculum.context())
1720
+ if (teachingContext) lines.push(teachingContext)
1701
1721
  lines.push(cursor
1702
1722
  ? `- 游标:${nameOf(cursor)}(${cursor})${node?.domain ? ` [${node.domain}]` : ''}`
1703
1723
  : '- 游标:**还没设** —— 先问清他现在在哪个知识点,用 coach_set_cursor 设上')
@@ -1951,6 +1971,43 @@ function apply(ctx) {
1951
1971
  } catch { /* 没这服务 / 没这文件 → 不注,照常加载 */ }
1952
1972
 
1953
1973
  ctx.tools.register(defineTool(curriculumTool(curriculum)))
1974
+ const teaching = createTeachingNotes({ loadProgress, saveProgress, loadMap,
1975
+ getSessionQuery: () => ctx.get?.('sessionQuery') })
1976
+ ctx.tools.register(defineTool({
1977
+ name: 'coach_note',
1978
+ description: '读取或保存简短续课笔记:讲到哪里、教练的判断、下次怎样接着讲。复用节点进度账本;不改掌握状态或自动决定下一课。' +
1979
+ 'studentQuote 可选;提供时自动核对当前会话的学生原话,核对存在不代表已经理解。无回应依据时写明尚未核对,不编造表现。',
1980
+ parameters: {
1981
+ action: { type: 'string', enum: ['read', 'write'], description: '默认 read;write 追加一条笔记。' },
1982
+ node: { type: 'string', description: '地图节点 id。写入默认游标;读取默认最近有笔记的未完动作,其次游标,无游标时读最近笔记。显式指定始终优先。' },
1983
+ limit: { type: 'integer', description: '读取最近几条,默认 5,最多 20。' },
1984
+ summary: { type: 'string', description: 'write 必填,实际讲授位置和教练判断,最多 1000 字;区分观察与推测。' },
1985
+ nextStep: { type: 'string', description: 'write 必填,下次续课建议,最多 500 字;后续教练可据新表现调整。' },
1986
+ studentQuote: { type: 'string', description: '可选,当前会话学生实际发过的一段原话,最多 1500 字;不需要手输消息 id。' },
1987
+ },
1988
+ output: { schema: { type: 'object', additionalProperties: false, properties: {
1989
+ ok: { type: 'boolean', required: true }, reject: { type: 'string', required: true },
1990
+ node: { type: 'string', required: true }, total: { type: 'integer', required: true },
1991
+ notes: { type: 'array', required: true, items: { type: 'object', additionalProperties: false, properties: {
1992
+ at: { type: 'string', required: true }, summary: { type: 'string', required: true }, nextStep: { type: 'string', required: true },
1993
+ studentEvidence: { type: 'object', additionalProperties: false, properties: {
1994
+ sessionId: { type: 'string', required: true }, seq: { type: 'integer', required: true },
1995
+ messageId: { type: 'string', required: true }, quote: { type: 'string', required: true },
1996
+ } },
1997
+ } } },
1998
+ } }, render: (_args, v) => [{ type: 'text', text: !v.ok ? `❌ ${v.reject}` : v.notes.length
1999
+ ? `续课笔记 · ${v.node}(最近 ${v.notes.length} / 共 ${v.total} 条)\n` + v.notes.map(note =>
2000
+ `${note.at}\n教练记录:${note.summary}\n续课建议:${note.nextStep}\n` +
2001
+ (note.studentEvidence ? `学生原话:「${note.studentEvidence.quote}」(存在已核对,理解程度仍由教练判断)`
2002
+ : '尚无学生回应依据;这条是教练笔记。')).join('\n\n')
2003
+ : `「${v.node || '当前节点'}」尚无续课笔记。` }] },
2004
+ execute: async (args = {}, exec) => {
2005
+ const action = args.action ?? 'read'
2006
+ if (action === 'read') return teaching.read(args)
2007
+ if (action === 'write') return teaching.write(args, exec)
2008
+ return { ok: false, reject: 'action 只能是 read 或 write。', node: '', notes: [], total: 0 }
2009
+ },
2010
+ }))
1954
2011
 
1955
2012
  // ── 目标驱动策略层:比赛目标 / VP 证据 / 复盘 / 当前重点 ─────────
1956
2013
  // 课程图回答“有哪些知识点”,策略层回答“为什么现在学这个”。两者分开,
@@ -2178,6 +2235,11 @@ function apply(ctx) {
2178
2235
  profile: { type: 'object', required: true, additionalProperties: false, properties: {
2179
2236
  id: { type: 'string', required: true }, title: { type: 'string', required: true }, scope: { type: 'string', required: true }, status: { type: 'string', required: true },
2180
2237
  sources: { type: 'array', required: true }, competencyIds: { type: 'array', required: true }, selectionGuidance: { type: 'array', required: true }, limitations: { type: 'array', required: true },
2238
+ samples: { type: 'array', required: true },
2239
+ coverage: { type: 'object', required: true, additionalProperties: false, properties: {
2240
+ sampledProblems: { type: 'integer', required: true }, reviewedSolutions: { type: 'integer', required: true },
2241
+ years: { type: 'array', required: true, items: { type: 'integer' } }, isSyllabus: { type: 'boolean', required: true },
2242
+ } },
2181
2243
  } }, daysRemaining: { type: 'integer', required: true },
2182
2244
  course: { type: 'object', required: true, additionalProperties: false, properties: {
2183
2245
  exists: { type: 'boolean', required: true }, revision: { type: 'integer', required: true },
@@ -2205,7 +2267,7 @@ function apply(ctx) {
2205
2267
  focusExpired: Boolean(snapshot.focusExpired), missingFreshEvidence: snapshot.missingFreshEvidence ?? [],
2206
2268
  }
2207
2269
  return { ...v, course, text: v.ok ? [`【训练取舍依据】长期:${course.goal || '尚未建立'}\n本次:${v.contest} · ${v.deadline} · 剩余${v.daysRemaining}天`,
2208
- `样本:${v.profile.title || '未选择'}(${v.profile.status})\n适用边界:${v.profile.limitations.join(';')}`,
2270
+ `样本:${v.profile.title || '未选择'}(${v.profile.status})\n覆盖:${v.profile.coverage.sampledProblems}题,${v.profile.coverage.reviewedSolutions}题已核对题解,年份 ${v.profile.coverage.years.join('、') || '无'};不是完整考纲。\n适用边界:${v.profile.limitations.join(';')}`,
2209
2271
  ...v.focus.map((f, i) => `${i + 1}. ${f.title}(${f.id})\n 比赛价值:${f.contestUse}\n 为什么现在:${f.whyNow}\n 关联知识点:${f.nodes.join('、') || '按比赛能力训练'}\n 近30天场次:确认短板${f.gapCount} / 独立起点${f.baselineCount} / 待确认${f.observationCount}`),
2210
2272
  course.exists ? `课程阶段:${course.phaseTitle}(${course.phaseId})\n课程主线:${course.focusNodes.join('、') || '能力证据驱动'}\n比赛能力:${course.phaseCompetencies.join('、') || '地图知识点'}` : '课程阶段:尚未建立,用 coach_curriculum assess/read/create 建立长期路线。',
2211
2273
  course.pendingNodes.length ? `课程未完成动作:${course.pendingNodes.join('、')}` : '',
@@ -2747,7 +2809,7 @@ function apply(ctx) {
2747
2809
  (v.suggestions.length ? `\n相近的有:${v.suggestions.join(' / ')}` : '\n也没找到相近的,确认一下写法。'),
2748
2810
  }]
2749
2811
  }
2750
- const TIER_TAG = { core: '★核心', normal: '常见', rare: '少见', skip: '不考' }
2812
+ const TIER_TAG = { core: '★核心', normal: '常见', rare: '少见', skip: '默认暂缓' }
2751
2813
  // (需求 #4):**每个节点都要出 entry**。
2752
2814
  // 原来是 `c.entry ? ' ≈'+c.entry : ''` —— 于是「没数据」和「够不着」
2753
2815
  // 在输出里长得一模一样(都是不显示),很容易把"没数据"读成
@@ -2979,41 +3041,40 @@ function apply(ctx) {
2979
3041
  // ① node 必须在地图里 → 挡住它编知识点
2980
3042
  // ② node 必须是游标的下一层 → 挡住它跳过前置乱指
2981
3043
  // ③ 前置必须都满足 → 挡住「学这个之前你得先学那个」这类空头指令
2982
- // ④ 时间盒不能短于 30 分钟 → 挡住排出没意义的表
3044
+ // ④ 时间盒必须是一天内的正整数分钟
2983
3045
  // 校验不过就**拒绝**,并把理由说清楚 —— 拒绝比给个错指令好。
2984
3046
  ctx.tools.register(defineTool({
2985
3047
  name: 'coach_assign',
2986
3048
  description:
2987
3049
  '布置**一个**训练动作。给训练员下指令时**必须**走这个工具,不要在正文里自由发挥 —— ' +
2988
- '它会把指令固定成「做什么 + 时间盒 + 交付物」,并校验你挑的知识点:' +
2989
- '必须存在于地图、必须是当前游标的下一层、前置必须都满足。校验不过会被拒绝并说明原因。' +
2990
- '它一次只收一个节点,所以**给不出候选列表** —— 这正是设计意图。' +
3050
+ '它保存「做什么 + 时间盒 + 交付物」,支持地图节点或已有比赛能力:' +
3051
+ '节点需符合游标或课程主线且前置满足;纯能力训练不要求游标。诊断和 VP 不强加做题收尾。' +
3052
+ '它一次只收一个训练目标;时长由教练按任务决定。' +
2991
3053
  '有长期课程时先读 coach_curriculum,带 curriculumRevision;主线必须属于当前阶段,' +
2992
3054
  '补漏或复习用 purpose 与 routeReason 说明关系和回归条件。课程节点可以跨图分支,但前置检查仍生效。' +
2993
- '有比赛目标时,progress 动作还必须落在当前目标能力;跨重点训练只能走课程绑定,' +
3055
+ '比赛目标与重点排序是决策参考,不会限制在排名前三;结合学生表现说明 why。' +
2994
3056
  '补漏/复习必须说明 routeReason。',
2995
3057
  parameters: {
2996
3058
  cursor: {
2997
3059
  type: 'string',
2998
3060
  description: '当前游标。不填就用 PROGRESS.yaml 里的(这是常态)。',
2999
3061
  },
3000
- node: { type: 'string', required: true, description: '地图知识点 id。无课程时从 coach_next 候选取;有课程时沿用当前主线,仍核查前置' },
3062
+ node: { type: 'string', description: '地图知识点 id;与 competency 二选一。无课程时沿用游标,有课程时核查当前主线与前置。' },
3063
+ competency: { type: 'string', description: '已有比赛能力 id,例如 contest-reading;纯能力训练不要求游标或虚构地图节点。与 node 二选一。' },
3064
+ kind: { type: 'string', enum: ['practice', 'diagnostic', 'vp'], description: '默认 practice(保留做题与收尾);diagnostic/vp 的 minutes 是整项时长,不强加收尾或讲课。' },
3001
3065
  deliverable: { type: 'string', required: true, description: '做完交什么,如「把代码贴给我」' },
3002
3066
  minutes: {
3003
3067
  type: 'number',
3004
- description:
3005
- `**他自己动手做题的净时间**(分钟)—— 止损点就是它,不是整块长度。` +
3006
- `默认 ${NET_DEFAULT_MIN},最低 ${NET_FLOOR_MIN}。` +
3007
- `整块长度 = 净时间 + ${TAIL_MIN}(解决遗留 10 + 重写 10,固定不压缩):` +
3008
- `净 ${NET_DEFAULT_MIN} → 整块 ${CYCLE_TOTAL_MIN},和日程表上的 cycle 块对得上。`,
3068
+ description: '1–960 的整数分钟。地图 practice 是自己动手的净时间(默认40,另加20收尾);diagnostic/vp或纯能力动作是整项时长。',
3009
3069
  },
3010
3070
  teachMinutes: {
3011
3071
  type: 'number',
3012
3072
  description:
3013
- '**先讲一遍**的时间(分钟)。开一个他从没碰过的知识点时**必须给**,' +
3073
+ 'practice 开全新地图知识点时先讲的时间(分钟);diagnostic/vp 不要求讲解。' +
3014
3074
  '不给会被拒绝 —— 直接扔题让他自己卡,那是给学过的人的练法。' +
3015
3075
  '讲多久**你自己定**(不参考任何固定值),看你判断他需要多少。' +
3016
- `最低 ${TEACH_FLOOR_MIN} 分钟,没有上限。已经在学或学过的节点不用给。`,
3076
+ `给了讲解则最低 ${TEACH_FLOOR_MIN} 分钟,整项动作不得超过960分钟。已经在学或学过的节点不用给。` +
3077
+ '这是计划时长,不能证明已经讲过或学生理解了。',
3017
3078
  },
3018
3079
  why: { type: 'string', description: '为什么挑它(一句话,进台账,供训练员质疑时举证)' },
3019
3080
  curriculumRevision: { type: 'integer', description: '有效课程 read 返回的 revision;无课程时可不填。' },
@@ -3030,6 +3091,7 @@ function apply(ctx) {
3030
3091
  cursor: { type: 'string', required: true },
3031
3092
  node: { type: 'string', required: true },
3032
3093
  nodeName: { type: 'string', required: true },
3094
+ competency: { type: 'string' },
3033
3095
  domain: { type: 'string', required: true },
3034
3096
  // 需求 #7:minutes 是**净时间**(他自己做多久);整块 = minutes + tailMinutes
3035
3097
  minutes: { type: 'integer', required: true },
@@ -3072,7 +3134,9 @@ function apply(ctx) {
3072
3134
  return [{
3073
3135
  type: 'text',
3074
3136
  text: `❌ 这条指令布置不了:${v.reject}\n\n` +
3075
- `**不要**改用候选列表交差 —— 先调 coach_next 重新挑一个就绪的,再调本工具。`,
3137
+ (v.competency
3138
+ ? '不要用候选列表代替指令;按拒绝原因核对能力、课程版本或时长,再调本工具。'
3139
+ : `**不要**改用候选列表交差 —— 先调 coach_next 重新挑一个就绪的,再调本工具。`),
3076
3140
  }]
3077
3141
  }
3078
3142
  const table = v.blocks
@@ -3083,7 +3147,7 @@ function apply(ctx) {
3083
3147
  text: [
3084
3148
  `【今天这一个动作】${v.nodeName} [${v.domain}]`,
3085
3149
  ``,
3086
- `做什么 ${v.nodeName}(地图节点 ${v.node})`,
3150
+ `做什么 ${v.nodeName}(${v.competency ? `比赛能力 ${v.competency}` : `地图节点 ${v.node}`})`,
3087
3151
  // 需求 #7:两个数都印出来 ——「同一个 40 两个意思」就是这条要治的病。
3088
3152
  `时间盒 自己做 ${v.minutes} + 收尾 ${v.tailMinutes} = 共 ${v.minutes + v.tailMinutes} 分钟` +
3089
3153
  (v.teachMinutes ? `(另有先讲 ${v.teachMinutes} 分钟,全场合计 ${v.totalMinutes})` : ''),
@@ -3113,12 +3177,15 @@ function apply(ctx) {
3113
3177
  const { byId, dependents, stamp } = loadMap()
3114
3178
  const cursor = resolveCursor(args?.cursor)
3115
3179
  const node = String(args?.node ?? '').trim()
3180
+ const competency = String(args?.competency ?? '').trim()
3181
+ const identity = trainingIdentity(node, competency)
3116
3182
  const deliverable = String(args?.deliverable ?? '').trim()
3183
+ const kind = String(args?.kind ?? 'practice').trim()
3184
+ const tail = kind === 'practice' && !competency ? TAIL_MIN : 0
3117
3185
  let why = String(args?.why ?? '').trim()
3118
3186
  // 需求 #7:这个数现在是**净时间**(他自己动手做多久),不是整块长度。
3119
3187
  // 整块 = 净 + TAIL_MIN(收尾固定 20),下面 blocks 和 totalMinutes 都照这个算。
3120
- const net = Number.isFinite(args?.minutes)
3121
- ? Math.trunc(args.minutes) : NET_DEFAULT_MIN
3188
+ const net = args?.minutes === undefined ? NET_DEFAULT_MIN : args.minutes
3122
3189
 
3123
3190
  // 校验失败一律走这里:回一个带原因的空壳,**不抛异常** ——
3124
3191
  // 抛异常会让对话断在那里,而拒绝是要让模型看见理由并改选的。
@@ -3126,7 +3193,7 @@ function apply(ctx) {
3126
3193
  accepted: false, reject,
3127
3194
  cursor, node, nodeName: byId.get(node)?.name ?? '',
3128
3195
  domain: byId.get(node)?.domain ?? '',
3129
- minutes: net, teachMinutes: 0, tailMinutes: TAIL_MIN, totalMinutes: 0, boxNote: '',
3196
+ minutes: Number.isInteger(net) ? net : 0, teachMinutes: 0, tailMinutes: tail, totalMinutes: 0, boxNote: '',
3130
3197
  deliverable, why, mapStamp: stamp, blocks: [], selfTarget: false,
3131
3198
  // 需求 #6:拒绝时也把这两个字段摆出来 —— 白名单式的 schema
3132
3199
  // (additionalProperties: false + required:true)要求每个出口同形,
@@ -3135,15 +3202,16 @@ function apply(ctx) {
3135
3202
  })
3136
3203
 
3137
3204
  // ① 游标认不认得。「没设」和「打错」分开提示,别让模型去猜。
3138
- if (!cursor) {
3205
+ if (!identity.ok) return deny(identity.reject)
3206
+ if (!competency && !cursor) {
3139
3207
  return deny('还没设游标(PROGRESS.yaml 里 cursor 是空的)。' +
3140
3208
  '先确认训练员现在在哪个知识点,用 coach_set_cursor 设上。')
3141
3209
  }
3142
- if (!byId.has(cursor)) {
3210
+ if (!competency && !byId.has(cursor)) {
3143
3211
  return deny(`地图里没有游标「${cursor}」。`)
3144
3212
  }
3145
3213
  // ② 节点必须真实存在 —— 挡住编造知识点
3146
- if (!byId.has(node)) {
3214
+ if (!competency && !byId.has(node)) {
3147
3215
  const near = suggest(node, [...byId.values()])
3148
3216
  return deny(
3149
3217
  `地图里没有「${node}」这个知识点,别自己造。` +
@@ -3151,7 +3219,7 @@ function apply(ctx) {
3151
3219
  }
3152
3220
  let route
3153
3221
  try {
3154
- route = curriculum.assignment({ node, revision: args?.curriculumRevision,
3222
+ route = curriculum.assignment({ node, competency, revision: args?.curriculumRevision,
3155
3223
  purpose: args?.purpose, reason: args?.routeReason })
3156
3224
  } catch (err) { return deny(`课程读取失败:${err.message};先核查,不凭印象布置。`) }
3157
3225
  if (!route.ok) return deny(route.reject)
@@ -3160,10 +3228,12 @@ function apply(ctx) {
3160
3228
  why = `课程「${route.binding.phaseTitle}」· ${route.binding.purpose}:${why}` +
3161
3229
  (route.binding.reason ? `;补充依据:${route.binding.reason}` : '')
3162
3230
  }
3163
- const targetRoute = strategy.route(node, { purpose: args?.purpose, courseBound: Boolean(route.binding) })
3231
+ const targetRoute = competency
3232
+ ? { ok: true, targetAligned: strategy.loadTarget().priorities.some(item => item.id === competency), competencyIds: [competency] }
3233
+ : strategy.route(node, { purpose: args?.purpose, courseBound: Boolean(route.binding) })
3164
3234
  if (!targetRoute.ok) return deny(targetRoute.reject)
3165
3235
  if (targetRoute.competencyIds.length) {
3166
- why = `目标能力「${targetRoute.competencyIds.join('、')}」:${why || '当前重点对应的训练动作'}`
3236
+ why = `目标能力「${targetRoute.competencyIds.join('、')}」:${why || '目标能力对应的训练动作'}`
3167
3237
  }
3168
3238
 
3169
3239
  // ③ 未建课程时沿用直接后继;有课程时允许当前阶段跨分支,仍检查前置。
@@ -3175,12 +3245,12 @@ function apply(ctx) {
3175
3245
  // 教练就一个动作都给不出来了 —— 它只能眼睁睁看着人卡住。
3176
3246
  //
3177
3247
  // 已经 verified 的不许再指回来:学完了就该往前走。
3178
- if (node === cursor) {
3248
+ if (!competency && node === cursor) {
3179
3249
  if (nodeStatus(loadProgress(), cursor) === 'verified' && route.binding?.purpose !== 'review') {
3180
3250
  return deny(`「${cursor}」已经验证过了 —— 学完了就往前走,` +
3181
3251
  '别在验证过的地方打转。用 coach_next 看下一层,或者往前沿退。')
3182
3252
  }
3183
- } else if (!(dependents.get(cursor) ?? []).includes(node) && !route.binding) {
3253
+ } else if (!competency && !(dependents.get(cursor) ?? []).includes(node) && !route.binding) {
3184
3254
  return deny(`「${node}」不是「${cursor}」的下一层。先调 coach_next 看它到底能开哪些。`)
3185
3255
  }
3186
3256
  // ④ 前置必须都满足 —— 挡住「你得先学那个」这类空头指令
@@ -3188,17 +3258,13 @@ function apply(ctx) {
3188
3258
  // 已学集合走**共用实现**:闭包(游标) ∪ 显式标为 学过/已验证 的节点。
3189
3259
  // 原先这里只展开游标自己,训练员标过的几十个节点全被当没看见。
3190
3260
  const assumed = assumedLearned(loadProgress(), byId, [cursor])
3191
- const missing = (byId.get(node).depends ?? []).filter((d) => !assumed.has(d))
3261
+ const missing = (byId.get(node)?.depends ?? []).filter((d) => !assumed.has(d))
3192
3262
  if (missing.length) {
3193
3263
  return deny(`「${node}」还缺前置:${missing.join('、')}。它现在还开不了。`)
3194
3264
  }
3195
3265
  // ⑤ 时间盒下限(需求 #7:比的是**净时间**,不是整块长度)
3196
- if (net < NET_FLOOR_MIN) {
3197
- return deny(
3198
- `净时间 ${net} 分钟太短,一个题目循环压不到这个长度。` +
3199
- '时间不够应该换更小的题,或者改用 review 块(25 分钟),' +
3200
- `而不是把循环压扁。(下限:自己做 ${NET_FLOOR_MIN} 分钟 = 整块 ${NET_FLOOR_MIN + TAIL_MIN} 分钟)`)
3201
- }
3266
+ if (!['practice', 'diagnostic', 'vp'].includes(kind)) return deny('kind 只能是 practice/diagnostic/vp。')
3267
+ if (!Number.isInteger(net) || net < NET_FLOOR_MIN || net > 960) return deny('minutes 必须是 1–960 的整数分钟。')
3202
3268
  if (!deliverable) return deny('没写交付物。做完交什么必须说清楚,否则训练员不知道什么时候算完。')
3203
3269
 
3204
3270
  // ⑥ **开新知识点必须先讲**。
@@ -3221,11 +3287,13 @@ function apply(ctx) {
3221
3287
  // 而它存在的理由正是防「直接出题」。判据用 taughtEvidence,
3222
3288
  // 和 coach_test 开卷、coach_status 报「讲于哪」是同一个实现。
3223
3289
  const taughtBefore = taughtEvidence(loadProgress(), node) !== null
3224
- const isNewNode = nodeStatus(loadProgress(), node) === 'none' &&
3290
+ const isNewNode = !competency && nodeStatus(loadProgress(), node) === 'none' &&
3225
3291
  !(node === cursor && taughtBefore)
3226
3292
  const teach = Number.isFinite(args?.teachMinutes) ? Math.trunc(args.teachMinutes) : 0
3293
+ if (args?.teachMinutes !== undefined && (!Number.isInteger(args.teachMinutes) || args.teachMinutes < 0 || args.teachMinutes > 960)) return deny('teachMinutes 必须是 0–960 的整数分钟。')
3294
+ if (net + tail + teach > 960) return deny('整项动作超过一天可训练的 960 分钟。')
3227
3295
 
3228
- if (isNewNode && teach <= 0) {
3296
+ if (kind === 'practice' && isNewNode && teach <= 0) {
3229
3297
  return deny(`「${byId.get(node)?.name ?? node}」是**全新知识点**(图上没人碰过),` +
3230
3298
  '而你没给讲解段。\n' +
3231
3299
  '开新知识点要**先拿一道例题讲一遍**再出题 —— 直接扔题让他自己卡,' +
@@ -3234,23 +3302,19 @@ function apply(ctx) {
3234
3302
  }
3235
3303
  if (teach > 0 && teach < TEACH_FLOOR_MIN) {
3236
3304
  return deny(`讲解 ${teach} 分钟太短 —— 讲一道例题至少 ${TEACH_FLOOR_MIN} 分钟,` +
3237
- '再短就只是念一遍结论,等于没讲。讲多久你定,**没有上限**。')
3305
+ '再短就只是念一遍结论,等于没讲。讲多久你定,整项时长需在一天内。')
3238
3306
  }
3239
3307
 
3240
- const n = byId.get(node)
3308
+ const n = { name: identity.name, domain: identity.domain }
3241
3309
  const solve = net // 需求 #7:净时间直接就是「自己做」那一段
3242
3310
 
3243
3311
  // 需求 #7 的两句提醒(只影响这段话,不挡布置):
3244
3312
  // · 压到默认以下 → 提醒它压的是**止损点**,不是块
3245
3313
  // · 净 ≠ 默认 → 提醒它这个整块长度**表上排不出来**(别让两个工具再打架)
3246
- const squeeze = net < NET_DEFAULT_MIN
3247
- ? `净 ${net} 分钟低于默认的净 ${NET_DEFAULT_MIN} —— 你压的是止损点,不是块;时间不够请换更小的题。`
3314
+ const shape = kind === 'practice' && net !== NET_DEFAULT_MIN
3315
+ ? `净 ${net} → 整块 ${net + tail} 分钟,排程时带 minutes=${net + tail + teach}。`
3248
3316
  : ''
3249
- const shape = net !== NET_DEFAULT_MIN
3250
- ? `净 ${net} → 整块 ${net + TAIL_MIN} 分钟,**表上排不出这个形状**` +
3251
- `(表上只有 cycle ${CYCLE_TOTAL_MIN} / review 25 / exam 90)。`
3252
- : ''
3253
- const boxNote = [squeeze, shape].filter(Boolean).join(' ')
3317
+ const boxNote = kind === 'practice' ? [shape].filter(Boolean).join(' ') : ''
3254
3318
 
3255
3319
  // ── 落盘:押上这个动作(需求 #6)─────────────────────
3256
3320
  // 布置的动作原先**只活在当次调用的返回里**:跨会话再开时教练不知道
@@ -3261,28 +3325,18 @@ function apply(ctx) {
3261
3325
  // 但要在输出里喊出来 —— 静默失败 = 下个会话重复布置。
3262
3326
  const progA = loadProgress()
3263
3327
  const pend = { ...(progA.pendingActions ?? {}) }
3264
- const replacedPending = Object.prototype.hasOwnProperty.call(pend, node)
3328
+ const replacedPending = Object.prototype.hasOwnProperty.call(pend, identity.key)
3265
3329
  const assignedAt = ymdToday()
3266
- pend[node] = {
3330
+ pend[identity.key] = {
3267
3331
  at: assignedAt, deliverable,
3268
3332
  // 存**拆开的**几个数,不存一个总数:总数在"整块 / 净时间"两种口径下
3269
3333
  // 含义不同,存总数等于把口径混进账本(那正是 #7 要治的病)。
3270
- solveMinutes: solve, tailMinutes: TAIL_MIN, teachMinutes: teach,
3271
- totalMinutes: net + teach + TAIL_MIN, why,
3272
- }
3273
- if (route.binding) pend[node].curriculum = route.binding
3274
- // 讲授段落盘:带 teachMinutes = 这次布置含讲授段,
3275
- // 在节点上记一条 `taught` 日期 —— 它是「讲过没」唯一的结构化痕迹。
3276
- // 在这之前讲完**不留任何记录**:coach_status 永远报「还没有教学记录」,
3277
- // 游标节点那条豁免也就没法按"讲过没"来判断(漏洞就是从这儿反推出来的)。
3278
- // 记的是"讲授段排上了"这一天,不是"讲得多好" —— 回答"讲过没"够用了。
3279
- if (teach > 0) {
3280
- const rec = { ...(progA.nodes[node] ?? {}) }
3281
- const taught = Array.isArray(rec.taught) ? rec.taught.slice() : []
3282
- if (!taught.includes(assignedAt)) taught.push(assignedAt)
3283
- rec.taught = taught
3284
- progA.nodes[node] = rec
3334
+ solveMinutes: solve, tailMinutes: tail, teachMinutes: teach,
3335
+ totalMinutes: net + teach + tail, why, kind,
3336
+ ...(competency ? { competency } : {}),
3285
3337
  }
3338
+ if (route.binding) pend[identity.key].curriculum = route.binding
3339
+ // 布置讲解只保存计划;接触状态与理解证据由各自入口记录。
3286
3340
  progA.pendingActions = pend
3287
3341
  let pendingRecorded = true
3288
3342
  try { saveProgress(progA) } catch { pendingRecorded = false }
@@ -3290,8 +3344,9 @@ function apply(ctx) {
3290
3344
  return {
3291
3345
  accepted: true, reject: '',
3292
3346
  cursor, node, nodeName: n.name ?? node, domain: n.domain ?? '',
3293
- minutes: net, teachMinutes: teach, tailMinutes: TAIL_MIN,
3294
- totalMinutes: net + teach + TAIL_MIN, boxNote,
3347
+ ...(competency ? { competency } : {}),
3348
+ minutes: net, teachMinutes: teach, tailMinutes: tail,
3349
+ totalMinutes: net + teach + tail, boxNote,
3295
3350
  deliverable, why, mapStamp: stamp, selfTarget: node === cursor,
3296
3351
  pendingRecorded, pendingReplaced: replacedPending,
3297
3352
  targetAligned: targetRoute.targetAligned, competencies: targetRoute.competencyIds,
@@ -3305,9 +3360,11 @@ function apply(ctx) {
3305
3360
  minutes: teach,
3306
3361
  }]
3307
3362
  : []),
3308
- { kind: 'solve', label: '自己做(限时,到点必须停)', minutes: solve },
3309
- { kind: 'resolve', label: '解决遗留(做出来了就复盘思路,没做出来就听讲)', minutes: 10 },
3310
- { kind: 'rewrite', label: '重写', minutes: 10 },
3363
+ { kind: kind === 'practice' ? 'solve' : kind, label: kind === 'vp' ? '完整 VP(比赛过程与复盘另按交付物记录)' : kind === 'diagnostic' ? '诊断(限时观察当前表现)' : '自己做(限时,到点必须停)', minutes: solve },
3364
+ ...(tail ? [
3365
+ { kind: 'resolve', label: '解决遗留(做出来了就复盘思路,没做出来就听讲)', minutes: 10 },
3366
+ { kind: 'rewrite', label: '重写', minutes: 10 },
3367
+ ] : []),
3311
3368
  ],
3312
3369
  }
3313
3370
  },
@@ -3332,6 +3389,7 @@ function apply(ctx) {
3332
3389
  // 让"讲过没"对教练**可查** —— 判据跟 coach_test 开卷时用的同一个
3333
3390
  // 函数(taughtEvidence),不另写一份。需求 #5 的验收 ③。
3334
3391
  cursorTaught: { type: 'string', required: true },
3392
+ teachingContext: { type: 'string', required: true },
3335
3393
  domain: { type: 'string', required: true },
3336
3394
  updated: { type: 'string', required: true },
3337
3395
  verifiedCount: { type: 'integer', required: true },
@@ -3384,6 +3442,7 @@ function apply(ctx) {
3384
3442
  nodeName: { type: 'string', required: true },
3385
3443
  date: { type: 'string', required: true },
3386
3444
  outcome: { type: 'string', required: true },
3445
+ independent: { type: 'boolean' },
3387
3446
  score: { type: 'integer', required: true },
3388
3447
  maxScore: { type: 'integer', required: true },
3389
3448
  minutes: { type: 'integer', required: true },
@@ -3468,6 +3527,8 @@ function apply(ctx) {
3468
3527
  node: { type: 'string', required: true },
3469
3528
  nodeName: { type: 'string', required: true },
3470
3529
  at: { type: 'string', required: true },
3530
+ competency: { type: 'string' },
3531
+ kind: { type: 'string' },
3471
3532
  // 押了几天。-1 = 日期不明(跟火候那边同一个约定,别让它算成 0)
3472
3533
  days: { type: 'integer', required: true },
3473
3534
  deliverable: { type: 'string', required: true },
@@ -3489,9 +3550,10 @@ function apply(ctx) {
3489
3550
  // 十有八九就是游标这个节点。埋在别处等于没报。(需求 #5 验收 ③)
3490
3551
  (v.cursorName
3491
3552
  ? (v.cursorTaught
3492
- ? ` 讲于 ${v.cursorTaught}`
3553
+ ? ` 接触记录 ${v.cursorTaught}(不证明理解)`
3493
3554
  : ` ⚠️ **还没有教学记录** —— 现在开卷会被拒,先讲`)
3494
3555
  : ''),
3556
+ v.teachingContext,
3495
3557
  // 押着的卷放最前面之一:它是个**卡住下一步的约束**(有新卷就开不了),
3496
3558
  // 埋在底下等于没报。
3497
3559
  v.pendingNode
@@ -3504,7 +3566,7 @@ function apply(ctx) {
3504
3566
  v.pendingActions.length
3505
3567
  ? '📌 **押着的动作**(布置了、还没落地):\n' +
3506
3568
  v.pendingActions.map((a) =>
3507
- ` · ${a.nodeName}${a.nodeName !== a.node ? `(${a.node})` : ''} ` +
3569
+ ` · ${a.nodeName}${a.node && a.nodeName !== a.node ? `(${a.node})` : ''} ` +
3508
3570
  `交付物 ${a.deliverable} ` +
3509
3571
  `时间盒 自己做 ${a.solveMinutes} + 收尾 ${a.tailMinutes} = ${a.solveMinutes + a.tailMinutes} 分钟` +
3510
3572
  (a.teachMinutes ? `,另加先讲 ${a.teachMinutes}(共 ${a.totalMinutes})` : '') +
@@ -3533,6 +3595,7 @@ function apply(ctx) {
3533
3595
  v.checkTotal
3534
3596
  ? `检测记录 ${v.checkTotal} 次,最近:` + v.recentChecks.slice(0, 4).map((c) =>
3535
3597
  `${c.date.slice(5)} ${c.nodeName} ${OUTCOME_TAG[c.outcome] ?? c.outcome}` +
3598
+ (c.outcome === 'passed' ? (c.independent === true ? '[独立]' : c.independent === false ? '[有帮助]' : '[独立性未知]') : '') +
3536
3599
  (c.outcome === 'overTime' ? `(${c.minutes}/${c.limit} 分钟)` : '') +
3537
3600
  (c.verifiedAt ? ` 验证于≈${c.verifiedAt}` : '') +
3538
3601
  (c.selfReported ? `[含 ${c.selfReported} 道自述题]` : '')).join(' · ')
@@ -3579,6 +3642,7 @@ function apply(ctx) {
3579
3642
  allChecks.push({
3580
3643
  node: id, nodeName: name,
3581
3644
  date: String(c?.date ?? ''), outcome: String(c?.outcome ?? ''),
3645
+ ...(typeof c?.independent === 'boolean' ? { independent: c.independent } : {}),
3582
3646
  score: Number(c?.score ?? 0), maxScore: Number(c?.maxScore ?? 0),
3583
3647
  minutes: Number(c?.minutes ?? 0), limit: Number(c?.limit ?? 0),
3584
3648
  verifiedAt: Number(c?.verifiedAt ?? 0),
@@ -3640,6 +3704,7 @@ function apply(ctx) {
3640
3704
  cursor: p.cursor,
3641
3705
  cursorName: byId.get(p.cursor)?.name ?? '',
3642
3706
  cursorTaught: taughtEvidence(p, p.cursor)?.first ?? '',
3707
+ teachingContext: teaching.context(p),
3643
3708
  domain: byId.get(p.cursor)?.domain ?? '',
3644
3709
  updated: p.updated,
3645
3710
  verifiedCount: verified.length,
@@ -3673,7 +3738,9 @@ function apply(ctx) {
3673
3738
  const then = parseYmd(at)
3674
3739
  const days = then ? Math.round((parseYmd(ymdToday()) - then) / 86400000) : -1
3675
3740
  return {
3676
- node: id, nodeName: byId.get(id)?.name ?? id, at, days,
3741
+ node: a?.competency ? '' : id,
3742
+ nodeName: a?.competency ? strategy.loadCompetencies().find(card => card.id === a.competency)?.title ?? a.competency : byId.get(id)?.name ?? id,
3743
+ ...(a?.competency ? { competency: String(a.competency), kind: String(a.kind ?? '') } : {}), at, days,
3677
3744
  deliverable: String(a?.deliverable ?? ''),
3678
3745
  solveMinutes: Number(a?.solveMinutes ?? 0),
3679
3746
  tailMinutes: Number(a?.tailMinutes ?? 0),
@@ -4925,25 +4992,19 @@ function apply(ctx) {
4925
4992
 
4926
4993
  // ── coach_grade:判卷 ────────────────────────────────────────
4927
4994
  //
4928
- // 检测闭环的收口。**「掌握」由检测决定,不由数据推断** —— 所以过了要写进
4929
- // verified:那是 verified 这一级的定义,不是顺手加的装饰。
4930
- //
4931
- // 三种结局**不能合并**:
4932
- // passed 分够 + 没超时 → 真过了,标 verified,可以推进
4933
- // overTime 分够 + 超时 → **不算过**。这是「不熟」的机械形态:
4934
- // 方法他有,就是慢。去向是加练,不是推进。
4935
- // failed 分不够 → 不过,交 coach_diagnose 判因
4995
+ // 卷面通过与独立验证分开:分数、用时只证明本次过卷;独立性未知不补成 true。
4996
+ // 历史 verified 不撤销,本次未独立验证也不刷新验证段位。
4936
4997
  //
4937
4998
  // 只依赖卷子本身(pending 里存了整份题目信息),不连数据库 ——
4938
4999
  // 判卷不该因为库被搬走或者锁了就失败。
4939
5000
  ctx.tools.register(defineTool({
4940
5001
  name: 'coach_grade',
4941
5002
  description:
4942
- '给正押着的那张检测卷判卷。他做完回来了,你收三样:每题 AC 没有、花了几分钟。\n' +
5003
+ '给正押着的那张检测卷判卷。收每题 AC 与否、实际分钟数和明确的独立性事实(未知就省略)。\n' +
4943
5004
  '判定:难易加权(易1/中2/难3,满分 6),**通过线 4 分且总用时不超过限时**。\n' +
4944
- '分够但超时 → 判「不熟」不算过(方法有,就是慢);分不够 → 不过。\n' +
4945
- '过了会自动把知识点标成「已验证」—— 那是 verified 的定义。\n' +
4946
- '判完给出下一步:过了就推进,不过就调 coach_diagnose 判因。',
5005
+ '分够但超时 → overTime;分不够 → failed。卷面通过且所有做出的题明确独立,才产生本次 verified。\n' +
5006
+ '接受过提示/题解/他人帮助,或独立性未知:只记过卷,保留原状态,不冒充独立掌握。\n' +
5007
+ '本次独立验证不证明隔日保持或迁移;下一步由教练结合课程和表现判断。',
4947
5008
  parameters: {
4948
5009
  results: {
4949
5010
  type: 'array',
@@ -4956,6 +5017,7 @@ function apply(ctx) {
4956
5017
  problemId: { type: 'string', required: true, description: '卷子里的题号,原样抄' },
4957
5018
  solved: { type: 'boolean', required: true, description: '这题最后 AC 了吗' },
4958
5019
  minutes: { type: 'number', required: true, description: '这题花了多少分钟(没做出来就填实际耗掉的)' },
5020
+ independent: { type: 'boolean', description: '这题是否未接受提示、题解或他人帮助而独立完成/尝试;未知就省略,不猜。' },
4959
5021
  },
4960
5022
  },
4961
5023
  },
@@ -4977,6 +5039,8 @@ function apply(ctx) {
4977
5039
  minutes: { type: 'integer', required: true },
4978
5040
  limit: { type: 'integer', required: true },
4979
5041
  status: { type: 'string', required: true },
5042
+ verified: { type: 'boolean', required: true, description: '本次卷面通过且所有做出题都有明确独立事实;不代表长期保持或迁移。' },
5043
+ independence: { type: 'string', required: true, description: '本次做出题的独立性:independent / assisted / unknown。' },
4980
5044
  verifiedAt: { type: 'integer', required: true },
4981
5045
  // 这张卷里有几道是「网上找的」(自述题)。>0 时这条 verified 的
4982
5046
  // 成色就要打折 —— 卷面上看得见,记录里也留痕。
@@ -4993,6 +5057,7 @@ function apply(ctx) {
4993
5057
  cfRating: { type: 'integer', required: true },
4994
5058
  solved: { type: 'boolean', required: true },
4995
5059
  minutes: { type: 'integer', required: true },
5060
+ independent: { type: 'boolean', description: '仅在本题明确报告独立性时返回。' },
4996
5061
  // 网上找的题的 cfRating 是自报估值 —— 不计入 verifiedAt(见上面的
4997
5062
  // solvedRatings 过滤)。来源得跟着结果走,不然判卷记录里分不出
4998
5063
  // 这道题是库里查的还是他自己找的。**schema 得声明它** ——
@@ -5007,7 +5072,7 @@ function apply(ctx) {
5007
5072
  render: (_args, v) => {
5008
5073
  if (!v.ok) return [{ type: 'text', text: `❌ 判不了:${v.reject}` }]
5009
5074
  const head = v.outcome === 'passed'
5010
- ? `✅ 过了 ${v.nodeName}`
5075
+ ? `✅ 卷面通过${v.verified ? ' · 本次独立验证' : ' · 未形成独立验证'} ${v.nodeName}`
5011
5076
  : v.outcome === 'overTime'
5012
5077
  ? `⏱ 分数够,但超时了 —— 这不算过 ${v.nodeName}`
5013
5078
  : `❌ 没过 ${v.nodeName}`
@@ -5019,29 +5084,29 @@ function apply(ctx) {
5019
5084
  // 直接影响这条 verified 值不值钱,而"过了"这两个字会盖过一切。
5020
5085
  ...(v.selfReported
5021
5086
  ? [`⚠️ 其中 ${v.selfReported} 道是**网上找的**:题号没进过题库、难度是估的、` +
5022
- '「做出来了」也只有他这句话。它不算进验证段位 —— 这条 verified 的成色比真题卷低,' +
5023
- '想坐实这个知识点,下次用题库里的题再测一次。']
5087
+ '做题结果是报告的事实,难度估值不计入验证段位;需要时用题库题再核对。']
5024
5088
  : []),
5025
5089
  '',
5026
5090
  ]
5027
5091
  for (const p of v.problems) {
5028
5092
  lines.push(` ${p.solved ? '✓' : '✗'} ${p.band} ${p.problemId} ${p.title} ` +
5029
- `${p.minutes} 分钟(权重 ${p.weight})`)
5093
+ `${p.minutes} 分钟(权重 ${p.weight};${p.independent === true ? '独立' : p.independent === false ? '有帮助' : '独立性未记录'})`)
5030
5094
  }
5031
5095
  lines.push('', '── 下一步 ──')
5032
5096
  if (v.outcome === 'passed') {
5033
- lines.push(` 这一节标成「已验证」了 —— 验证于 ≈${v.verifiedAt} 段位。` +
5034
- '(等他 rating 涨上去,这个段位的题也会变难,到时候该复检)')
5097
+ lines.push(v.verified
5098
+ ? ` 本次达到独立验证条件${v.verifiedAt ? `,题库难度上限≈${v.verifiedAt}` : ',没有可核对的题库难度段位'};不代表隔日仍会或能迁移。`
5099
+ : v.independence === 'assisted'
5100
+ ? ' 卷面过了,但做出题有帮助:保留原状态,不新增掌握证书;需要时再独立检测。'
5101
+ : ' 卷面过了,但独立性未记录:保留原状态,不新增掌握证书;不要把未知补成独立。')
5035
5102
  const course = curriculum.execute({ action: 'read' })
5036
5103
  lines.push(course.exists || !course.ok
5037
5104
  ? ' 先读 coach_curriculum 检查当前阶段验收;单点过卷不会自动推进阶段。继续当前主线,整体成果达标后再 advance。'
5038
- : ' 用 coach_assign 布置下一节 —— **推进,别回头**。')
5105
+ : ' 根据实际理解和独立表现,选择补练、复检、迁移或下一节。')
5039
5106
  } else if (v.outcome === 'overTime') {
5040
- lines.push(' 他不是不会,是慢。别推进,也别当「不会」去补课 —— ' +
5041
- '换个同难度的题限时加练,把速度压进限时里。')
5107
+ lines.push(' 本次超过限时。结合做题过程判断是建模、实现还是速度问题,再决定补讲或限时加练。')
5042
5108
  } else {
5043
- lines.push(' 让他把没做出来的那题代码贴过来,调 coach_diagnose 判因 —— ' +
5044
- '分清是「不会」还是「不认真」,再决定补课还是重写。')
5109
+ lines.push(' 结合没做出题的题面、代码和过程,用 coach_diagnose 判因,再决定补讲、练习或重写。')
5045
5110
  }
5046
5111
  return [{ type: 'text', text: lines.join('\n') }]
5047
5112
  },
@@ -5052,7 +5117,7 @@ function apply(ctx) {
5052
5117
  const deny = (reject) => ({
5053
5118
  ok: false, reject, node: '', nodeName: '', domain: '',
5054
5119
  outcome: '', score: 0, maxScore: 0, passScore: PASS_SCORE,
5055
- minutes: 0, limit: 0, status: '', verifiedAt: 0, problems: [], checks: 0,
5120
+ minutes: 0, limit: 0, status: '', verified: false, independence: 'unknown', verifiedAt: 0, selfReported: 0, problems: [], checks: 0,
5056
5121
  })
5057
5122
 
5058
5123
  const prog = loadProgress()
@@ -5076,6 +5141,11 @@ function apply(ctx) {
5076
5141
  const keyOf = (id) => normId('nowcoder', String(id ?? '').trim())
5077
5142
  const byIdGiven = new Map()
5078
5143
  for (const r of given) {
5144
+ if (!r || typeof r.problemId !== 'string' || !r.problemId.trim() || typeof r.solved !== 'boolean' ||
5145
+ typeof r.minutes !== 'number' || !Number.isFinite(r.minutes) || r.minutes < 0 || !Number.isSafeInteger(Math.round(r.minutes)) ||
5146
+ (r.independent !== undefined && typeof r.independent !== 'boolean')) {
5147
+ return deny('每条结果必须有题号、布尔 solved 和非负有限分钟数;independent 只能是布尔值或省略。')
5148
+ }
5079
5149
  const key = keyOf(r?.problemId)
5080
5150
  if (byIdGiven.has(key)) return deny(`题号 ${r.problemId} 报了两遍。`)
5081
5151
  byIdGiven.set(key, r)
@@ -5093,6 +5163,7 @@ function apply(ctx) {
5093
5163
  }
5094
5164
  const solved = r.solved === true
5095
5165
  const spent = Math.max(0, Math.round(Number(r.minutes) || 0))
5166
+ if (!Number.isSafeInteger(minutes + spent)) return deny('总分钟数超出安全数值范围,不能记录。')
5096
5167
  if (solved) score += Number(q.weight) || 0
5097
5168
  minutes += spent
5098
5169
  graded.push({
@@ -5104,6 +5175,7 @@ function apply(ctx) {
5104
5175
  // 老卷子可能没有(那批 pending 是在加这个字段之前开的),兜底 0。
5105
5176
  cfRating: Math.round(Number(q.cfRating) || 0),
5106
5177
  solved, minutes: spent,
5178
+ ...(typeof r.independent === 'boolean' ? { independent: r.independent } : {}),
5107
5179
  })
5108
5180
  }
5109
5181
 
@@ -5113,9 +5185,13 @@ function apply(ctx) {
5113
5185
  // 超时和分数不够是**两种病**:前者是不熟,后者是不会。
5114
5186
  const outcome = score < passScore ? 'failed'
5115
5187
  : (limit > 0 && minutes > limit) ? 'overTime' : 'passed'
5188
+ const solvedProblems = graded.filter((g) => g.solved)
5189
+ const independence = solvedProblems.some((g) => g.independent === false) ? 'assisted'
5190
+ : solvedProblems.length && solvedProblems.every((g) => g.independent === true) ? 'independent' : 'unknown'
5191
+ const verified = outcome === 'passed' && independence === 'independent'
5116
5192
 
5117
5193
  const node = String(p.node ?? '')
5118
- const status = outcome === 'passed' ? 'verified'
5194
+ const status = verified ? 'verified'
5119
5195
  : nodeStatus(prog, node) === 'none' ? 'studying' : nodeStatus(prog, node)
5120
5196
 
5121
5197
  // 「验证于哪个段位」= 他拿下的题里 cfRating 最高的那个。
@@ -5130,7 +5206,7 @@ function apply(ctx) {
5130
5206
  const solvedRatings = graded
5131
5207
  .filter((g) => g.solved && g.source !== 'web')
5132
5208
  .map((g) => g.cfRating).filter((r) => r > 0)
5133
- const verifiedAt = outcome === 'passed' && solvedRatings.length
5209
+ const verifiedAt = verified && solvedRatings.length
5134
5210
  ? Math.max(...solvedRatings) : 0
5135
5211
 
5136
5212
  // 自述题计数。
@@ -5149,10 +5225,10 @@ function apply(ctx) {
5149
5225
  date: localStamp(new Date()).slice(0, 10),
5150
5226
  outcome, score, maxScore, passScore,
5151
5227
  minutes, limit, verifiedAt, selfReported, problems: graded,
5228
+ ...(independence === 'unknown' ? {} : { independent: independence === 'independent' }),
5152
5229
  })
5153
5230
  nodeRec.checks = checks
5154
- // 过 → verified(这是那一级的定义)。不过**不降级**已有的 learned/verified,
5155
- // 但若本来就没学过,标成「在学」—— 他显然正在这上面花时间。
5231
+ // 未达到独立验证条件不降级旧状态;未学节点只记录正在学习。
5156
5232
  const prevStatus = nodeStatus(prog, node)
5157
5233
  nodeRec.status = status
5158
5234
  // ⚠️ 修过:这条路**以前没写 `at`**。
@@ -5171,7 +5247,7 @@ function apply(ctx) {
5171
5247
  return {
5172
5248
  ok: true, reject: '', node, nodeName: byId.get(node)?.name ?? node,
5173
5249
  domain: byId.get(node)?.domain ?? '', outcome, score, maxScore, passScore,
5174
- minutes, limit, status, verifiedAt, selfReported,
5250
+ minutes, limit, status, verified, independence, verifiedAt, selfReported,
5175
5251
  problems: graded, checks: checks.length,
5176
5252
  }
5177
5253
  },
@@ -5323,6 +5399,7 @@ function apply(ctx) {
5323
5399
  type: 'string',
5324
5400
  description: '撤哪个节点的动作。不填 = 手上只有一条时撤那条;有多条会被拒绝并列出来。',
5325
5401
  },
5402
+ competency: { type: 'string', description: '撤哪个比赛能力动作,与 node 二选一;都不填时手上只有一条可直接撤。' },
5326
5403
  reason: { type: 'string', description: '为什么撤(一句话,回显用)' },
5327
5404
  },
5328
5405
  output: {
@@ -5337,6 +5414,7 @@ function apply(ctx) {
5337
5414
  at: { type: 'string', required: true },
5338
5415
  deliverable: { type: 'string', required: true },
5339
5416
  reason: { type: 'string', required: true },
5417
+ competency: { type: 'string' },
5340
5418
  remaining: { type: 'integer', required: true },
5341
5419
  },
5342
5420
  },
@@ -5362,6 +5440,13 @@ function apply(ctx) {
5362
5440
  const prog = loadProgress()
5363
5441
  const pend = { ...(prog.pendingActions ?? {}) }
5364
5442
  let node = String(args?.node ?? '').trim()
5443
+ let competency = String(args?.competency ?? '').trim()
5444
+ if (node && competency) return deny('node 与 competency 只能指定一个。')
5445
+ if (competency) {
5446
+ const identity = trainingIdentity('', competency)
5447
+ if (!identity.ok) return deny(identity.reject)
5448
+ node = identity.key
5449
+ }
5365
5450
  if (!node) {
5366
5451
  const keys = Object.keys(pend)
5367
5452
  if (!keys.length) {
@@ -5378,6 +5463,7 @@ function apply(ctx) {
5378
5463
  '可能已经落地(coach_log 销的),或者被撤过了。')
5379
5464
  }
5380
5465
  const a = pend[node]
5466
+ competency = String(a?.competency ?? '')
5381
5467
  delete pend[node]
5382
5468
  prog.pendingActions = pend
5383
5469
  try {
@@ -5386,7 +5472,9 @@ function apply(ctx) {
5386
5472
  return deny(`写盘失败:${err.message} —— 动作还押着,别当它撤了。`)
5387
5473
  }
5388
5474
  return {
5389
- ok: true, reject: '', node, nodeName: byId.get(node)?.name ?? node,
5475
+ ok: true, reject: '', node: competency ? '' : node,
5476
+ nodeName: trainingIdentity(competency ? '' : node, competency).name ?? node,
5477
+ ...(competency ? { competency } : {}),
5390
5478
  at: String(a?.at ?? ''), deliverable: String(a?.deliverable ?? ''),
5391
5479
  reason, remaining: Object.keys(pend).length,
5392
5480
  }
@@ -5466,6 +5554,7 @@ function apply(ctx) {
5466
5554
  kindName: { type: 'string', required: true },
5467
5555
  node: { type: 'string', required: true },
5468
5556
  nodeName: { type: 'string', required: true },
5557
+ competency: { type: 'string' },
5469
5558
  },
5470
5559
  },
5471
5560
  },
@@ -5480,6 +5569,7 @@ function apply(ctx) {
5480
5569
  actualMin: { type: 'integer', required: true },
5481
5570
  solved: { type: 'boolean', required: true },
5482
5571
  independent: { type: 'boolean', required: true },
5572
+ competency: { type: 'string' },
5483
5573
  // 可选(coach_log 带 note 时才写)。不写 required —— 可选键
5484
5574
  // 在 dsh 那套里就是"不写 required",写 required:false 会抛。
5485
5575
  note: { type: 'string' },
@@ -5593,10 +5683,8 @@ function apply(ctx) {
5593
5683
  L.push(` ${r.nodeName} ${tag} ${ago}${rec}`)
5594
5684
  }
5595
5685
  L.push('',
5596
- '⚠️ **复习别把新知识挤没了。** 这套系统的价值在"往前推",不在"原地保温" —— ' +
5597
- '一个月的表里全是复习,等于这个月没往前走。他自己会想先复习(复习舒服、' +
5598
- '做新题难受),**所以那个平衡得你来把**:复习块按上面的火候排,' +
5599
- '剩下的时间留给推进。')
5686
+ '根据目标、剩余时间和实际表现,选择新课、复习、迁移或 VP。' +
5687
+ '上面的间隔只供判断;不要为了填满课表持续开新知识点。')
5600
5688
  }
5601
5689
  L.push('', '要排就用 coach_plan,**一次只排他说的那一天**。')
5602
5690
  return [{ type: 'text', text: L.join('\n') }]
@@ -5625,8 +5713,8 @@ function apply(ctx) {
5625
5713
  '**范围只认他说的那一天**:他说「排今天」就只排今天,说「排周三」就只排周三,' +
5626
5714
  '不要顺手把明天后天一起铺了 —— 那是替他做决定,而且是没问过他的决定。\n' +
5627
5715
  '三种块:cycle = 题目循环 60min(40 自己做 + 10 解决遗留 + 10 重写)、' +
5628
- 'review = 复习一题 25min、exam = 章节检测 90min。**时长不能改** —— ' +
5629
- '40 分钟那个止损点全靠它守着。\n' +
5716
+ 'review = 复习一题 25min、exam = 章节检测 90min;diagnostic 默认 15min、vp 默认 300min。' +
5717
+ '这些是默认值;按任务需要用 minutes 明确时长。\n' +
5630
5718
  '工具只拦**物理上做不到的**:压着他标的忙事 / 块叠在一起 / 知识点不在图上 / ' +
5631
5719
  '日子在窗口外。**排多排少是你的判断,工具不给容量上限**,排太满的后果' +
5632
5720
  '让表里的' +
@@ -5637,13 +5725,15 @@ function apply(ctx) {
5637
5725
  date: { type: 'string', required: true, description: '哪天,YYYY-MM-DD,得在今天 ~ 今天+6 之间' },
5638
5726
  blocks: {
5639
5727
  type: 'array', required: true,
5640
- description: '要排的块,按时间先后给。结束时间由 kind 决定,不用你算',
5728
+ description: '要排的块,按时间先后给。结束时间由 minutes 或 kind 默认时长决定',
5641
5729
  items: {
5642
5730
  type: 'object', additionalProperties: false,
5643
5731
  properties: {
5644
5732
  from: { type: 'string', required: true, description: '开始时间 "HH:MM",要对齐 5 分钟' },
5645
- kind: { type: 'string', required: true, enum: ['cycle', 'review', 'exam'] },
5646
- node: { type: 'string', required: true, description: '知识点 id,必须在地图里' },
5733
+ kind: { type: 'string', required: true, enum: ['cycle', 'review', 'exam', 'diagnostic', 'vp'] },
5734
+ node: { type: 'string', description: '地图知识点 id;与 competency 二选一。' },
5735
+ competency: { type: 'string', description: '已有比赛能力 id;与 node 二选一。' },
5736
+ minutes: { type: 'integer', description: '整块时长 1–960 分钟;不填保留该 kind 的默认时长。' },
5647
5737
  },
5648
5738
  },
5649
5739
  },
@@ -5671,6 +5761,7 @@ function apply(ctx) {
5671
5761
  node: { type: 'string', required: true },
5672
5762
  nodeName: { type: 'string', required: true },
5673
5763
  domain: { type: 'string', required: true },
5764
+ competency: { type: 'string' },
5674
5765
  },
5675
5766
  },
5676
5767
  },
@@ -5700,7 +5791,7 @@ function apply(ctx) {
5700
5791
  (v.replaced ? `(覆盖掉原来那 ${v.replaced} 个块)` : '')]
5701
5792
  for (const b of v.planned) {
5702
5793
  L.push(` ★ ${b.from}–${b.to} ${b.kindName} ${b.nodeName}` +
5703
- (b.kind === 'cycle' ? ' (40 自己做 → 10 解决遗留 → 10 重写)' : ''))
5794
+ (b.kind === 'cycle' && b.minutes === CYCLE_TOTAL_MIN ? ' (40 自己做 → 10 解决遗留 → 10 重写)' : ''))
5704
5795
  }
5705
5796
  // 回一条**完整时间轴**,而不是「还剩多少分钟」。
5706
5797
  // 排完他得能一眼看出这块夹在哪两件事之间 —— 那个位置才决定来不来得及。
@@ -5722,6 +5813,8 @@ function apply(ctx) {
5722
5813
  async execute(args) {
5723
5814
  const blocks = (Array.isArray(args?.blocks) ? args.blocks : []).map((b) => ({
5724
5815
  from: String(b?.from ?? ''), kind: String(b?.kind ?? ''), node: String(b?.node ?? ''),
5816
+ ...(b?.competency ? { competency: String(b.competency).trim() } : {}),
5817
+ ...(b?.minutes !== undefined ? { minutes: b.minutes } : {}),
5725
5818
  }))
5726
5819
  const r = planDay(args?.date, blocks, { replace: args?.replace === true })
5727
5820
 
@@ -5795,6 +5888,7 @@ function apply(ctx) {
5795
5888
  kindName: { type: 'string', required: true },
5796
5889
  node: { type: 'string', required: true },
5797
5890
  nodeName: { type: 'string', required: true },
5891
+ competency: { type: 'string' },
5798
5892
  },
5799
5893
  },
5800
5894
  },
@@ -5809,6 +5903,7 @@ function apply(ctx) {
5809
5903
  kindName: { type: 'string', required: true },
5810
5904
  node: { type: 'string', required: true },
5811
5905
  nodeName: { type: 'string', required: true },
5906
+ competency: { type: 'string' },
5812
5907
  },
5813
5908
  },
5814
5909
  },
@@ -5879,7 +5974,7 @@ function apply(ctx) {
5879
5974
  ctx.tools.register(defineTool({
5880
5975
  name: 'coach_log',
5881
5976
  description:
5882
- '记一条**实际用时**:他做完一个块之后,报「做了多久、是不是独立做出来的」。\n' +
5977
+ '记一条节点练习或比赛能力动作的**实际用时**:他做完一个块之后,报「做了多久、是不是独立做出来的」。\n' +
5883
5978
  '**每次他报告完成情况就调一次**(不管是做出来了还是没做出来、超时了还是提前),' +
5884
5979
  '这是系统学会"他真实速度是多少"的唯一来源 —— 不记,排程永远只能靠猜。\n' +
5885
5980
  '计划时长不用你填:那天表上排了多久,工具自己从 SCHEDULE.yaml 里算。\n' +
@@ -5888,7 +5983,8 @@ function apply(ctx) {
5888
5983
  '**同一天同一个知识点做了两道题,就调两次、各带自己的 problemId** —— ' +
5889
5984
  '别合成一条(合成之后「哪道独立做出来的」就没了,而那正是开不开检测卷的依据)。',
5890
5985
  parameters: {
5891
- node: { type: 'string', required: true, description: '知识点 id,必须在地图里' },
5986
+ node: { type: 'string', description: '知识点 id;与 competency 二选一。' },
5987
+ competency: { type: 'string', description: '已有比赛能力 id;记录能力诊断或完整 VP,与 node 二选一。' },
5892
5988
  // (需求 #2):题号。**可选** —— 不填就是老行为(一天一节点一条)。
5893
5989
  problemId: {
5894
5990
  type: 'string',
@@ -5920,6 +6016,7 @@ function apply(ctx) {
5920
6016
  date: { type: 'string', required: true },
5921
6017
  node: { type: 'string', required: true },
5922
6018
  nodeName: { type: 'string', required: true },
6019
+ competency: { type: 'string' },
5923
6020
  plannedMin: { type: 'integer', required: true },
5924
6021
  actualMin: { type: 'integer', required: true },
5925
6022
  deviation: { type: 'integer', required: true },
@@ -5963,7 +6060,7 @@ function apply(ctx) {
5963
6060
  ? `\n (这天这个点${v.problemId ? '这道题' : ''}原来记过一条,覆盖了)`
5964
6061
  : '') +
5965
6062
  (v.recorded
5966
- ? `\n 台账:这个知识点第 ${v.passCount} 次做完记录。`
6063
+ ? v.competency ? '\n 台账:比赛能力动作已记入实际记录;不证明长期掌握。' : `\n 台账:这个知识点第 ${v.passCount} 次做完记录。`
5967
6064
  : '\n ⚠️ 掌握证据那条没写上(磁盘/权限问题)—— 时间记录是好的。') +
5968
6065
  (v.actionCleared
5969
6066
  ? '\n 📌 这个节点上押着的动作**销了** —— 这条记录就是它的落地。'
@@ -5980,6 +6077,8 @@ function apply(ctx) {
5980
6077
  const { byId } = loadMap()
5981
6078
  const node = String(args?.node ?? '').trim()
5982
6079
  const d = String(args?.date ?? '').trim() || ymdToday()
6080
+ const competency = String(args?.competency ?? '').trim()
6081
+ const identity = trainingIdentity(node, competency)
5983
6082
  const actualMin = Math.round(Number(args?.actualMin))
5984
6083
  const solved = args?.solved === true
5985
6084
  const independent = args?.independent === true
@@ -5996,7 +6095,8 @@ function apply(ctx) {
5996
6095
  if (!/^\d{4}-\d{2}-\d{2}$/.test(d)) {
5997
6096
  return deny(`日期要 YYYY-MM-DD 格式,收到「${d}」。`)
5998
6097
  }
5999
- if (!byId.has(node)) {
6098
+ if (!identity.ok) return deny(identity.reject)
6099
+ if (!competency && !byId.has(node)) {
6000
6100
  const near = suggest(node, [...byId.values()])
6001
6101
  return deny(`地图里没有「${node}」这个知识点,别自己造。` +
6002
6102
  (near.length ? `相近的有:${near.join(' / ')}` : '先调 coach_status 看他在哪。'))
@@ -6023,7 +6123,7 @@ function apply(ctx) {
6023
6123
  return a === null || z === null ? 0 : Math.max(0, z - a)
6024
6124
  }
6025
6125
  const plannedMin = (Array.isArray(day.planned) ? day.planned : [])
6026
- .filter((b) => String(b?.node ?? '') === node)
6126
+ .filter((b) => competency ? b?.competency === competency : String(b?.node ?? '') === node && !b?.competency)
6027
6127
  .reduce((t, b) => t + blockMin(b), 0)
6028
6128
 
6029
6129
  // 同一天同一个点:**没给题号时**只留一条 —— 这张表没有开始时间,
@@ -6032,15 +6132,20 @@ function apply(ctx) {
6032
6132
  // (09-21 的 ST 表:P2880 独立 AC、1548B 带提示 AC),压成一条等于
6033
6133
  // 把"他独立做出过这个点的题"从台账里抹掉。
6034
6134
  const idx = list.findIndex((r) =>
6035
- String(r?.node ?? '') === node && String(r?.problemId ?? '') === problemId)
6135
+ (competency ? r?.competency === competency : String(r?.node ?? '') === node && !r?.competency) && String(r?.problemId ?? '') === problemId)
6036
6136
  // 需求 #8:`plannedMin` 不再写进这条记录(见上面那段注释)
6037
6137
  const entry = { node, actualMin, solved, independent }
6138
+ const pendingAction = loadProgress().pendingActions?.[identity.key]
6139
+ if (competency) {
6140
+ entry.competency = competency
6141
+ entry.kind = pendingAction?.kind ?? 'diagnostic'
6142
+ }
6038
6143
  if (problemId) entry.problemId = problemId
6039
6144
  if (note) entry.note = note
6040
6145
  const replaced = idx >= 0
6041
6146
  if (replaced) list[idx] = entry
6042
6147
  else list.push(entry)
6043
- const sameDayEntries = list.filter((r) => String(r?.node ?? '') === node).length
6148
+ const sameDayEntries = list.filter((r) => competency ? r?.competency === competency : String(r?.node ?? '') === node && !r?.competency).length
6044
6149
 
6045
6150
  const w = setScheduleField(d, 'actual', list)
6046
6151
  if (!w.ok) return deny(w.reject)
@@ -6061,35 +6166,39 @@ function apply(ctx) {
6061
6166
  let passCount = 0
6062
6167
  try {
6063
6168
  const prog = loadProgress()
6064
- const rec = { ...(prog.nodes[node] ?? {}) }
6065
- const passes = Array.isArray(rec.passes) ? rec.passes.slice() : []
6066
- const e = { date: d, solved, independent, minutes: actualMin }
6067
- if (problemId) e.problemId = problemId
6068
- if (note) e.note = note
6069
- // 去重键和 SCHEDULE 那边**必须一致**(日期 + 节点 + 题号):
6070
- // 两边口径分家的话,`coach_status` 的条目数和 `coach_next` 的计划
6071
- // 会对不上同一件事 —— 这个项目反复在治的就是这个病。
6072
- const pi = passes.findIndex((p) => String(p?.date ?? '') === d &&
6073
- String(p?.problemId ?? '') === problemId)
6074
- if (pi >= 0) passes[pi] = e
6075
- else passes.push(e)
6076
- rec.passes = passes
6077
- prog.nodes[node] = rec
6169
+ if (!competency) {
6170
+ const rec = { ...(prog.nodes[node] ?? {}) }
6171
+ const passes = Array.isArray(rec.passes) ? rec.passes.slice() : []
6172
+ const e = { date: d, solved, independent, minutes: actualMin }
6173
+ if (problemId) e.problemId = problemId
6174
+ if (note) e.note = note
6175
+ // 去重键和 SCHEDULE 那边**必须一致**(日期 + 节点 + 题号):
6176
+ // 两边口径分家的话,`coach_status` 的条目数和 `coach_next` 的计划
6177
+ // 会对不上同一件事 —— 这个项目反复在治的就是这个病。
6178
+ const pi = passes.findIndex((p) => String(p?.date ?? '') === d &&
6179
+ String(p?.problemId ?? '') === problemId)
6180
+ if (pi >= 0) passes[pi] = e
6181
+ else passes.push(e)
6182
+ rec.passes = passes
6183
+ prog.nodes[node] = rec
6184
+ passCount = passes.length
6185
+ }
6078
6186
  // 押着的动作**落地即销**(需求 #6):这个节点上布置过、他这会儿记了
6079
6187
  // 一条做完记录 = 就是做了。撤销是 coach_unassign 的活(那条路不写 passes)——
6080
6188
  // 两个出口分开,是为了不让"做完了"和"不做了"在账上长成一个样。
6081
- if (prog.pendingActions && Object.prototype.hasOwnProperty.call(prog.pendingActions, node)) {
6082
- delete prog.pendingActions[node]
6189
+ if (prog.pendingActions && Object.prototype.hasOwnProperty.call(prog.pendingActions, identity.key)) {
6190
+ delete prog.pendingActions[identity.key]
6083
6191
  actionCleared = true
6084
6192
  }
6085
6193
  saveProgress(prog)
6086
6194
  recorded = true
6087
- passCount = passes.length
6088
- } catch { /* 落盘失败不影响时间那条已经写进去的记录 */ }
6195
+ if (competency) passCount = sameDayEntries
6196
+ } catch { actionCleared = false /* 日程已保存,进度未保存,动作仍在。 */ }
6089
6197
 
6090
6198
  return {
6091
6199
  ok: true, reject: '', date: d, node,
6092
- nodeName: byId.get(node)?.name ?? node,
6200
+ nodeName: identity.name,
6201
+ ...(competency ? { competency } : {}),
6093
6202
  plannedMin, actualMin, deviation: actualMin - plannedMin, solved, independent,
6094
6203
  replaced, problemId, sameDayEntries,
6095
6204
  totalLogged: list.length, recorded, actionCleared, passCount, updated: w.updated,
@@ -6097,8 +6206,8 @@ function apply(ctx) {
6097
6206
  },
6098
6207
  }))
6099
6208
 
6100
- // 强制力那一层(方针 §五 第四层):每个 turn 注入当前位置
6101
- registerStateHook(ctx)
6209
+ // 每个 turn 注入当前位置与续课线索,教学判断仍交给小鲸。
6210
+ registerStateHook(ctx, teaching)
6102
6211
 
6103
6212
  // 技能树页面(可选注入 —— 没有 webServer 的环境跳过,工具照常工作)
6104
6213
  ctx.inject(['webServer'], (sctx) => { registerPages(sctx) })