acmer-coach 1.2.1 → 2.1.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
@@ -23,18 +23,19 @@
23
23
  // · `file:` 依赖装出来的是**硬链接**,不是拷贝。原地写两边同变,但改名式
24
24
  // 写入(Edit 工具、VS Code 原子保存)会**断开链接**,断了两边分家,
25
25
  // 而 `dsh plugin add` 认不出分家、不重链也不报错 —— 所以改完一律跑 dev.sh。
26
- import { readFileSync, writeFileSync, renameSync, statSync } from 'node:fs'
26
+ import { copyFileSync, readFileSync, unlinkSync, writeFileSync, renameSync, statSync } from 'node:fs'
27
27
  import { join } from 'node:path'
28
28
  import { homedir } from 'node:os'
29
29
  import { parse, stringify } from 'yaml'
30
30
  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
+ import { createStrategyStore } from './lib/strategy.js'
33
34
 
34
35
  const name = 'acmer-coach'
35
36
  const inject = ['tools']
36
37
 
37
- const VERSION = '1.2.1'
38
+ const VERSION = '2.1.0'
38
39
 
39
40
  // 块结构(方针 §4.1.1):40 自己做 + 10 解决遗留 + 10 重写 = 60 分钟。
40
41
  // 这是**一个题目循环**的固定形状。时间不够该换更小的题,不是把块压扁。
@@ -70,7 +71,14 @@ const TEACH_FLOOR_MIN = 10
70
71
  const DATA_DIR_EXPLICIT = Boolean(process.env.COACH_DATA_DIR)
71
72
  const DATA_DIR = process.env.COACH_DATA_DIR || join(homedir(), '.dsh', 'knowledge')
72
73
  const MAP_PATH = () => join(DATA_DIR, 'MAP.yaml')
73
- const curriculum = createCurriculumStore({ dataDir: DATA_DIR, loadMap, loadProgress, loadSchedule })
74
+ const strategy = createStrategyStore({
75
+ dataDir: DATA_DIR,
76
+ competenciesPath: join(import.meta.dirname, 'assets', 'knowledge', 'COMPETENCIES.yaml'),
77
+ })
78
+ const curriculum = createCurriculumStore({
79
+ dataDir: DATA_DIR, loadMap, loadProgress, loadSchedule,
80
+ loadStrategyEvidence: () => strategy.evidence().evidence,
81
+ })
74
82
 
75
83
  // 探针要的文件,各自代表一条后面的依赖:
76
84
  // MAP.yaml 地图(coach_next 读它算候选)
@@ -251,6 +259,16 @@ const PROGRESS_HEADER = `
251
259
  // 原子写:先写 .tmp 再 rename。半路崩了也不会把真相源撕成半个文件。
252
260
  // ⚠️ 千万不要在解析失败时静默返回空进度再写回 —— 那是**拿空数据覆盖真相源**。
253
261
  // loadProgress 遇到非 ENOENT 的错一律抛,就是要让这种情况炸出来。
262
+ function replaceFile(tmp, destination) {
263
+ try { renameSync(tmp, destination) } catch (err) {
264
+ if (!['EPERM', 'EEXIST'].includes(err.code)) throw err
265
+ // Windows sandbox may refuse rename over an existing file; replace from the complete temp copy.
266
+ try { unlinkSync(destination) } catch (removeErr) { if (removeErr.code !== 'ENOENT') throw err }
267
+ copyFileSync(tmp, destination)
268
+ unlinkSync(tmp)
269
+ }
270
+ }
271
+
254
272
  function saveProgress(p) {
255
273
  p.updated = localStamp(new Date()).slice(0, 10)
256
274
  // 显式列出要落盘的字段,而不是 `stringify(p)` 一把梭 ——
@@ -271,7 +289,7 @@ function saveProgress(p) {
271
289
  }
272
290
  const tmp = `${PROGRESS_PATH()}.tmp`
273
291
  writeFileSync(tmp, PROGRESS_HEADER + stringify(out, { lineWidth: 0 }), 'utf8')
274
- renameSync(tmp, PROGRESS_PATH())
292
+ replaceFile(tmp, PROGRESS_PATH())
275
293
  return p
276
294
  }
277
295
 
@@ -365,7 +383,7 @@ function saveSchedule(s) {
365
383
  // days 的 key 本身就是日期,也裸着 —— PyYAML 会读成 Date,按字符串索引就 KeyError
366
384
  body = body.replace(/^(\s*)(\d{4}-\d{2}-\d{2}):(\s*)$/gm, '$1"$2":')
367
385
  writeFileSync(tmp, SCHEDULE_HEADER + body, 'utf8')
368
- renameSync(tmp, SCHEDULE_PATH())
386
+ replaceFile(tmp, SCHEDULE_PATH())
369
387
  return s
370
388
  }
371
389
 
@@ -1602,12 +1620,15 @@ function registerStateHook(ctx) {
1602
1620
  // 课程可以先于游标存在;即使尚未布置动作,也要在新会话继续同一路线。
1603
1621
  let hasCurriculum = false
1604
1622
  try { hasCurriculum = Boolean(curriculum.load()) } catch { hasCurriculum = true }
1605
- if (!cursor && !pending && !studying.length && !hasCurriculum) return decision
1623
+ let hasTarget = false
1624
+ try { hasTarget = Boolean(strategy.loadTarget().contest) } catch { hasTarget = true }
1625
+ if (!cursor && !pending && !studying.length && !hasCurriculum && !hasTarget) return decision
1606
1626
 
1607
1627
  const { byId } = loadMap()
1608
1628
  const nameOf = (id) => byId.get(String(id))?.name ?? String(id)
1609
1629
  const node = byId.get(cursor)
1610
1630
  const lines = ['<coach_state>', '(教练插件注入的位置,不用再调工具问)']
1631
+ lines.push(strategy.context())
1611
1632
  lines.push(curriculum.context())
1612
1633
  lines.push(cursor
1613
1634
  ? `- 游标:${nameOf(cursor)}(${cursor})${node?.domain ? ` [${node.domain}]` : ''}`
@@ -1813,6 +1834,137 @@ function apply(ctx) {
1813
1834
 
1814
1835
  ctx.tools.register(defineTool(curriculumTool(curriculum)))
1815
1836
 
1837
+ // ── 目标驱动策略层:比赛目标 / VP 证据 / 复盘 / 当前重点 ─────────
1838
+ // 课程图回答“有哪些知识点”,策略层回答“为什么现在学这个”。两者分开,
1839
+ // 避免把 rating 或游标误当成区域赛目标。
1840
+ const strategyText = (v) => [{ type: 'text', text: v.ok ? v.text : `❌ ${v.reject}` }]
1841
+ ctx.tools.register(defineTool({
1842
+ name: 'coach_target',
1843
+ description: '设置或读取比赛目标。目标先于知识点:contest/date/result/teamMode/weeklyHours/priorities 都确定后,教练才有依据选训练重点。',
1844
+ parameters: {
1845
+ action: { type: 'string', required: true, enum: ['read', 'set', 'clear'] },
1846
+ expectedRevision: { type: 'integer', description: 'read 返回的 revision;首次 set 用 0。' },
1847
+ contest: { type: 'string', description: '比赛名称,例如西安区域赛' },
1848
+ date: { type: 'string', description: '比赛日期 YYYY-MM-DD' },
1849
+ result: { type: 'string', description: '目标结果,例如区域赛金牌' },
1850
+ teamMode: { type: 'string', description: 'solo 或 team' },
1851
+ weeklyHours: { type: 'number', description: '每周可用训练小时' },
1852
+ constraints: { type: 'object', additionalProperties: true, description: '可选时间约束,例如 weekday/saturday/sunday' },
1853
+ priorities: { type: 'array', description: '能力优先级 [{id, weight, reason}],weight 越大越优先', items: { type: 'object', additionalProperties: false, properties: {
1854
+ id: { type: 'string', required: true }, weight: { type: 'number', required: true }, reason: { type: 'string' },
1855
+ } } },
1856
+ },
1857
+ output: { schema: { type: 'object', additionalProperties: false, properties: {
1858
+ ok: { type: 'boolean', required: true }, reject: { type: 'string', required: true }, revision: { type: 'integer', required: true },
1859
+ contest: { type: 'string', required: true }, date: { type: 'string', required: true }, result: { type: 'string', required: true },
1860
+ teamMode: { type: 'string', required: true }, weeklyHours: { type: 'number', required: true }, priorities: { type: 'array', required: true },
1861
+ updated: { type: 'string', required: true }, version: { type: 'integer', required: true },
1862
+ constraints: { type: 'object', required: true, additionalProperties: true }, text: { type: 'string', required: true },
1863
+ } }, render: (_a, v) => strategyText(v) },
1864
+ execute: (args) => {
1865
+ const v = strategy.target(args?.action || 'read', args ?? {})
1866
+ return { ...v, text: v.ok
1867
+ ? `【比赛目标 · revision ${v.revision}】${v.contest} / ${v.date}\n目标:${v.result}\n模式:${v.teamMode};每周 ${v.weeklyHours} 小时\n能力重点:${v.priorities.map((p) => `${p.id}(${p.weight})`).join('、')}`
1868
+ : '' }
1869
+ },
1870
+ }))
1871
+
1872
+ ctx.tools.register(defineTool({
1873
+ name: 'coach_vp_import',
1874
+ description: '导入 VP 或模拟赛记录。它保存比赛过程证据,让教练根据读题、耗时、提交和题目选择复盘,不再只看 CF rating。重复 eventId 会更新,不会重复堆积。',
1875
+ parameters: {
1876
+ events: { type: 'array', required: true, items: { type: 'object', additionalProperties: true,
1877
+ description: '每场含 eventId/contest/date/problems;problem 含 problemId/status/readMinutes/solveMinutes/attempts/competencies' } },
1878
+ },
1879
+ output: { schema: { type: 'object', additionalProperties: false, properties: {
1880
+ ok: { type: 'boolean', required: true }, reject: { type: 'string', required: true }, imported: { type: 'integer', required: true },
1881
+ total: { type: 'integer', required: true }, revision: { type: 'integer', required: true }, invalid: { type: 'array', required: true, items: { type: 'string' } }, text: { type: 'string', required: true },
1882
+ } }, render: (_a, v) => strategyText(v) },
1883
+ execute: (args) => {
1884
+ const v = strategy.vpImport(Array.isArray(args?.events) ? args.events : [])
1885
+ return { ok: v.ok, reject: v.reject, imported: v.imported, total: v.total, revision: v.revision, invalid: v.invalid ?? [],
1886
+ text: v.ok ? `✓ VP 记录已保存:本次 ${v.imported} 场,累计 ${v.total} 场,revision ${v.revision}` : '' }
1887
+ },
1888
+ }))
1889
+
1890
+ ctx.tools.register(defineTool({
1891
+ name: 'coach_postmortem',
1892
+ description: '记录一场 VP 的赛后复盘。至少写能力问题、根因或下一步动作之一;复盘会影响 coach_focus 的优先级。',
1893
+ parameters: {
1894
+ eventId: { type: 'string', required: true }, competencies: { type: 'array', items: { type: 'string' } },
1895
+ missedReads: { type: 'array', items: { type: 'string' } }, decisionErrors: { type: 'array', items: { type: 'string' } },
1896
+ teamIssues: { type: 'array', items: { type: 'string' } }, rootCauses: { type: 'array', items: { type: 'string' } },
1897
+ nextActions: { type: 'array', items: { type: 'string' } }, note: { type: 'string' }, date: { type: 'string' },
1898
+ },
1899
+ output: { schema: { type: 'object', additionalProperties: false, properties: {
1900
+ ok: { type: 'boolean', required: true }, reject: { type: 'string', required: true }, eventId: { type: 'string', required: true },
1901
+ revision: { type: 'integer', required: true }, postmortems: { type: 'array', required: true }, text: { type: 'string', required: true },
1902
+ } }, render: (_a, v) => strategyText(v) },
1903
+ execute: (args) => {
1904
+ const v = strategy.postmortem(args ?? {})
1905
+ return { ...v, text: v.ok ? `✓ 已记录 ${v.eventId} 的复盘;当前共 ${v.postmortems.length} 条复盘,revision ${v.revision}` : '' }
1906
+ },
1907
+ }))
1908
+
1909
+ ctx.tools.register(defineTool({
1910
+ name: 'coach_focus',
1911
+ description: '根据比赛目标权重和 VP 复盘证据生成当前训练重点。它输出能力、比赛价值、为什么现在学、关联知识点和暂缓项;没有目标时拒绝猜方向。',
1912
+ parameters: {},
1913
+ output: { schema: { type: 'object', additionalProperties: false, properties: {
1914
+ ok: { type: 'boolean', required: true }, reject: { type: 'string', required: true }, contest: { type: 'string', required: true },
1915
+ deadline: { type: 'string', required: true }, focus: { type: 'array', required: true }, postponed: { type: 'array', required: true },
1916
+ evidenceEvents: { type: 'integer', required: true }, postmortems: { type: 'integer', required: true }, text: { type: 'string', required: true },
1917
+ course: { type: 'object', required: true, additionalProperties: false, properties: {
1918
+ exists: { type: 'boolean', required: true }, revision: { type: 'integer', required: true },
1919
+ phaseId: { type: 'string', required: true }, phaseTitle: { type: 'string', required: true },
1920
+ focusNodes: { type: 'array', required: true, items: { type: 'string' } }, pendingNodes: { type: 'array', required: true, items: { type: 'string' } },
1921
+ phaseCompetencies: { type: 'array', required: true, items: { type: 'string' } },
1922
+ focusExpired: { type: 'boolean', required: true }, missingFreshEvidence: { type: 'array', required: true, items: { type: 'string' } },
1923
+ } },
1924
+ } }, render: (_a, v) => strategyText(v) },
1925
+ execute: () => {
1926
+ const v = strategy.focus()
1927
+ let snapshot
1928
+ try { snapshot = curriculum.snapshot() } catch (err) {
1929
+ snapshot = { exists: false, revision: 0, phaseId: '', phaseTitle: '', focusNodes: [], pendingNodes: [], focusExpired: false, missingFreshEvidence: [] }
1930
+ v.ok = false
1931
+ v.reject = v.reject || `课程读取失败:${err.message}`
1932
+ }
1933
+ const course = {
1934
+ exists: Boolean(snapshot.exists), revision: Number(snapshot.revision || 0), phaseId: String(snapshot.phaseId || ''),
1935
+ phaseTitle: String(snapshot.phaseTitle || ''), focusNodes: snapshot.focusNodes ?? [], pendingNodes: snapshot.pendingNodes ?? [],
1936
+ phaseCompetencies: snapshot.phaseCompetencies ?? [],
1937
+ focusExpired: Boolean(snapshot.focusExpired), missingFreshEvidence: snapshot.missingFreshEvidence ?? [],
1938
+ }
1939
+ return { ...v, course, text: v.ok ? [`【当前训练重点】${v.contest} · 截止 ${v.deadline}`,
1940
+ ...v.focus.map((f, i) => `${i + 1}. ${f.title}(${f.id})\n 比赛价值:${f.contestUse}\n 为什么现在:${f.whyNow}\n 关联知识点:${f.nodes.join('、') || '需先定义'}\n 证据次数:${f.evidenceCount}`),
1941
+ course.exists ? `课程阶段:${course.phaseTitle}(${course.phaseId})\n课程主线:${course.focusNodes.join('、') || '能力证据驱动'}\n比赛能力:${course.phaseCompetencies.join('、') || '地图知识点'}` : '课程阶段:尚未建立,用 coach_curriculum assess/read/create 建立长期路线。',
1942
+ course.pendingNodes.length ? `课程未完成动作:${course.pendingNodes.join('、')}` : '',
1943
+ v.postponed.length ? `暂缓:${v.postponed.join('、')}` : '没有暂缓项。',
1944
+ ].join('\n') : '' }
1945
+ },
1946
+ }))
1947
+
1948
+ ctx.tools.register(defineTool({
1949
+ name: 'coach_scope',
1950
+ description: '查看一个比赛能力或知识点的适用范围、延伸、反例、常见错误、迁移题和退出条件。没有范围卡片时明确拒绝编造。',
1951
+ parameters: { id: { type: 'string', required: true, description: '能力 id 或地图节点 id' } },
1952
+ output: { schema: { type: 'object', additionalProperties: false, properties: {
1953
+ ok: { type: 'boolean', required: true }, reject: { type: 'string', required: true }, found: { type: 'boolean', required: true },
1954
+ id: { type: 'string', required: true }, title: { type: 'string', required: true }, contestUse: { type: 'string', required: true },
1955
+ nodes: { type: 'array', required: true }, appliesTo: { type: 'array', required: true }, extensions: { type: 'array', required: true }, notFor: { type: 'array', required: true },
1956
+ commonFailures: { type: 'array', required: true }, transferProblems: { type: 'array', required: true }, exitEvidence: { type: 'array', required: true },
1957
+ text: { type: 'string', required: true },
1958
+ } }, render: (_a, v) => strategyText(v) },
1959
+ execute: (args) => {
1960
+ const v = strategy.scope(args?.id)
1961
+ return { ...v, text: v.ok ? [`【范围卡片】${v.title}(${v.id})`, `比赛价值:${v.contestUse}`,
1962
+ `适用:${v.appliesTo.join('、')}`, `延伸:${v.extensions.join('、')}`, `不要用于:${v.notFor.join('、')}`,
1963
+ `常见错误:${v.commonFailures.join('、')}`, `迁移:${v.transferProblems.join('、')}`, `退出条件:${v.exitEvidence.join('、')}`,
1964
+ ].join('\n') : '' }
1965
+ },
1966
+ }))
1967
+
1816
1968
  ctx.tools.register(defineTool({
1817
1969
  name: 'coach_setup',
1818
1970
  description:
@@ -2563,7 +2715,9 @@ function apply(ctx) {
2563
2715
  '必须存在于地图、必须是当前游标的下一层、前置必须都满足。校验不过会被拒绝并说明原因。' +
2564
2716
  '它一次只收一个节点,所以**给不出候选列表** —— 这正是设计意图。' +
2565
2717
  '有长期课程时先读 coach_curriculum,带 curriculumRevision;主线必须属于当前阶段,' +
2566
- '补漏或复习用 purpose 与 routeReason 说明关系和回归条件。课程节点可以跨图分支,但前置检查仍生效。',
2718
+ '补漏或复习用 purpose 与 routeReason 说明关系和回归条件。课程节点可以跨图分支,但前置检查仍生效。' +
2719
+ '有比赛目标时,progress 动作还必须落在当前目标能力;跨重点训练只能走课程绑定,' +
2720
+ '补漏/复习必须说明 routeReason。',
2567
2721
  parameters: {
2568
2722
  cursor: {
2569
2723
  type: 'string',
@@ -2622,6 +2776,8 @@ function apply(ctx) {
2622
2776
  pendingRecorded: { type: 'boolean', required: true },
2623
2777
  // 这个节点上原来就押着一条(被这次覆盖)。
2624
2778
  pendingReplaced: { type: 'boolean', required: true },
2779
+ targetAligned: { type: 'boolean', required: true },
2780
+ competencies: { type: 'array', required: true, items: { type: 'string' } },
2625
2781
  blocks: {
2626
2782
  type: 'array',
2627
2783
  required: true,
@@ -2660,6 +2816,7 @@ function apply(ctx) {
2660
2816
  v.boxNote ? `⚠️ ${v.boxNote}` : '',
2661
2817
  `交付物 ${v.deliverable}`,
2662
2818
  v.why ? `为什么 ${v.why}` : '',
2819
+ v.competencies.length ? `目标对齐 ✓ ${v.competencies.join('、')}` : '',
2663
2820
  ``,
2664
2821
  table,
2665
2822
  v.teachMinutes
@@ -2700,7 +2857,7 @@ function apply(ctx) {
2700
2857
  // 需求 #6:拒绝时也把这两个字段摆出来 —— 白名单式的 schema
2701
2858
  // (additionalProperties: false + required:true)要求每个出口同形,
2702
2859
  // 缺一个 verify-shape 那一关就会红。
2703
- pendingRecorded: false, pendingReplaced: false,
2860
+ pendingRecorded: false, pendingReplaced: false, targetAligned: false, competencies: [],
2704
2861
  })
2705
2862
 
2706
2863
  // ① 游标认不认得。「没设」和「打错」分开提示,别让模型去猜。
@@ -2729,6 +2886,11 @@ function apply(ctx) {
2729
2886
  why = `课程「${route.binding.phaseTitle}」· ${route.binding.purpose}:${why}` +
2730
2887
  (route.binding.reason ? `;补充依据:${route.binding.reason}` : '')
2731
2888
  }
2889
+ const targetRoute = strategy.route(node, { purpose: args?.purpose, courseBound: Boolean(route.binding) })
2890
+ if (!targetRoute.ok) return deny(targetRoute.reject)
2891
+ if (targetRoute.competencyIds.length) {
2892
+ why = `目标能力「${targetRoute.competencyIds.join('、')}」:${why || '当前重点对应的训练动作'}`
2893
+ }
2732
2894
 
2733
2895
  // ③ 未建课程时沿用直接后继;有课程时允许当前阶段跨分支,仍检查前置。
2734
2896
  //
@@ -2858,6 +3020,7 @@ function apply(ctx) {
2858
3020
  totalMinutes: net + teach + TAIL_MIN, boxNote,
2859
3021
  deliverable, why, mapStamp: stamp, selfTarget: node === cursor,
2860
3022
  pendingRecorded, pendingReplaced: replacedPending,
3023
+ targetAligned: targetRoute.targetAligned, competencies: targetRoute.competencyIds,
2861
3024
  blocks: [
2862
3025
  // 讲解段排在最前面,`kind: teach` —— 卷面上看得见,
2863
3026
  // 它才不会变成"讲完就忘了排"的那个动作
@@ -4,7 +4,7 @@ const text = (value, field, phase) => {
4
4
  if (result.length < 10) throw new Error(`阶段 ${phase} 的 ${field} 需要至少 10 字的具体依据。`)
5
5
  return result
6
6
  }
7
- const coreKeys = ['id', 'title', 'outcome', 'nodes', 'exitCriteria', 'requiredVerified']
7
+ const coreKeys = ['id', 'title', 'outcome', 'nodes', 'competencies', 'exitCriteria', 'requiredVerified']
8
8
  export const corePhase = (phase) => Object.fromEntries(coreKeys.map((key) => [key, phase[key]]))
9
9
 
10
10
  export function prerequisitesOf(nodes, byId) {
@@ -37,6 +37,7 @@ export function normalizePhases(input, byId, { legacy = false, completed = [] }
37
37
  const p = {
38
38
  id: clean(phase?.id), title: clean(phase?.title), outcome: clean(phase?.outcome),
39
39
  nodes: Array.isArray(phase?.nodes) ? phase.nodes.map(clean) : [],
40
+ competencies: Array.isArray(phase?.competencies) ? [...new Set(phase.competencies.map(clean).filter(Boolean))] : [],
40
41
  exitCriteria: clean(phase?.exitCriteria),
41
42
  requiredVerified: Array.isArray(phase?.requiredVerified) ? phase.requiredVerified.map(clean)
42
43
  : (kind === 'training' || kind === 'legacy' ? [...(phase?.nodes ?? [])].map(clean) : []),
@@ -44,7 +45,7 @@ export function normalizePhases(input, byId, { legacy = false, completed = [] }
44
45
  if (!p.id || p.id.length > 80 || ids.has(p.id)) throw new Error('阶段 id 必须非空且唯一,最长 80 字符。')
45
46
  ids.add(p.id)
46
47
  if (!p.title || !p.outcome || !p.exitCriteria) throw new Error(`阶段 ${p.id} 缺标题、能力成果或验收条件。`)
47
- if (!p.nodes.length || p.nodes.length > 50 || new Set(p.nodes).size !== p.nodes.length) throw new Error(`阶段 ${p.id} 需要 1–50 个不重复的地图节点。`)
48
+ if ((!p.nodes.length && !p.competencies.length) || p.nodes.length > 50 || new Set(p.nodes).size !== p.nodes.length) throw new Error(`阶段 ${p.id} 至少需要一个地图节点或比赛能力,地图节点最多 50 个。`)
48
49
  for (const node of p.nodes) if (!byId.has(node)) throw new Error(`阶段 ${p.id} 的节点「${node}」不在地图里。`)
49
50
  if (new Set(p.requiredVerified).size !== p.requiredVerified.length || p.requiredVerified.some((node) => !p.nodes.includes(node))) throw new Error(`阶段 ${p.id} 的必验节点必须属于该阶段,且不能重复。`)
50
51
  const prerequisites = prerequisitesOf(p.nodes, byId)
@@ -85,7 +86,9 @@ export function validatePlan(phases, { byId, progress, catalog, current = 0 }) {
85
86
  for (const ref of phase.evidenceRefs) {
86
87
  const entry = records.get(ref)
87
88
  if (!entry) throw new Error(`阶段 ${phase.id} 的证据不存在或内容已变:${ref}。先 assess 重新取引用。`)
88
- if (!phase.nodes.includes(entry.node)) throw new Error(`阶段 ${phase.id} 引用了无关节点「${entry.node}」的记录。`)
89
+ const nodeMatch = entry.node && phase.nodes.includes(entry.node)
90
+ const competencyMatch = entry.competency && phase.competencies.includes(entry.competency)
91
+ if (!nodeMatch && !competencyMatch) throw new Error(`阶段 ${phase.id} 引用了无关的证据记录「${ref}」。`)
89
92
  }
90
93
  if (phase.kind !== 'training') continue
91
94
  const signal = phase.finding === 'gap' ? 'gap' : 'baseline'
@@ -94,6 +97,11 @@ export function validatePlan(phases, { byId, progress, catalog, current = 0 }) {
94
97
  return entry.node === node && entry.signal === signal
95
98
  }))
96
99
  if (uncovered.length) throw new Error(`阶段 ${phase.id} 的${signal === 'gap' ? '短板' : '进阶起点'}缺真实依据:${uncovered.join('、')}。先诊断;rating、状态或一段理由不能代替记录。`)
100
+ const missingCompetencies = phase.competencies.filter((competency) => !phase.evidenceRefs.some((ref) => {
101
+ const entry = records.get(ref)
102
+ return entry.competency === competency && entry.signal === signal
103
+ }))
104
+ if (missingCompetencies.length) throw new Error(`阶段 ${phase.id} 的${signal === 'gap' ? '短板' : '进阶起点'}缺真实能力依据:${missingCompetencies.join('、')}。先记录 VP 或复盘,不用 rating 代替。`)
97
105
  // 同阶段节点按依赖顺序列出;只有前阶段的必验训练节点能作为未来前置。
98
106
  for (const node of phase.nodes) {
99
107
  const missing = prerequisitesOf([node], byId).filter((dependency) => !available.has(dependency))
@@ -106,6 +114,9 @@ export function validatePlan(phases, { byId, progress, catalog, current = 0 }) {
106
114
  export function missingFreshEvidence(phase, catalog, entryEvidenceRefs, enteredDate, entryEvidenceEvents = []) {
107
115
  const old = new Set(entryEvidenceRefs)
108
116
  const events = new Set(entryEvidenceEvents)
109
- return phase.nodes.filter((node) => !catalog.some((entry) => entry.node === node && !old.has(entry.id) &&
117
+ const missingNodes = phase.nodes.filter((node) => !catalog.some((entry) => entry.node === node && !old.has(entry.id) &&
118
+ !events.has(entry.eventId) && entry.date >= enteredDate && (phase.kind === 'diagnostic' || entry.signal === 'baseline')))
119
+ const missingCompetencies = phase.competencies.filter((competency) => !catalog.some((entry) => entry.competency === competency && !old.has(entry.id) &&
110
120
  !events.has(entry.eventId) && entry.date >= enteredDate && (phase.kind === 'diagnostic' || entry.signal === 'baseline')))
121
+ return [...missingNodes, ...missingCompetencies]
111
122
  }
package/lib/curriculum.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // 课程路线保存教学决定;进度和检测证据仍从 PROGRESS 读取。
2
- import { readFileSync, writeFileSync, renameSync, unlinkSync } from 'node:fs'
2
+ import { copyFileSync, readFileSync, writeFileSync, renameSync, unlinkSync } from 'node:fs'
3
3
  import { join } from 'node:path'
4
4
  import { randomUUID } from 'node:crypto'
5
5
  import { parse, stringify } from 'yaml'
@@ -17,13 +17,18 @@ function validDate(value) {
17
17
  const d = new Date(`${value}T12:00:00Z`)
18
18
  return Number.isFinite(d.getTime()) && d.toISOString().slice(0, 10) === value
19
19
  }
20
- export function createCurriculumStore({ dataDir, loadMap, loadProgress, loadSchedule = () => ({ days: {} }), now = () => new Date() }) {
20
+ export function createCurriculumStore({ dataDir, loadMap, loadProgress, loadSchedule = () => ({ days: {} }), loadStrategyEvidence = () => [], now = () => new Date() }) {
21
21
  const path = join(dataDir, 'CURRICULUM.yaml')
22
- const evidence = () => collectEvidence(loadProgress(), loadSchedule()).filter((entry) => loadMap().byId.has(entry.node))
22
+ const evidence = () => [
23
+ ...collectEvidence(loadProgress(), loadSchedule()).filter((entry) => loadMap().byId.has(entry.node)),
24
+ ...loadStrategyEvidence().filter((entry) => entry && typeof entry.id === 'string' && typeof entry.competency === 'string'),
25
+ ]
23
26
  function enter(doc, catalog) {
24
27
  doc.enteredAt = now().toISOString()
25
- doc.entryEvidenceRefs = catalog.filter((entry) => doc.phases[doc.current].nodes.includes(entry.node)).map((entry) => entry.id)
26
- doc.entryEvidenceEvents = catalog.filter((entry) => doc.phases[doc.current].nodes.includes(entry.node)).map((entry) => entry.eventId)
28
+ const phase = doc.phases[doc.current]
29
+ const inPhase = catalog.filter((entry) => phase.nodes.includes(entry.node) || phase.competencies.includes(entry.competency))
30
+ doc.entryEvidenceRefs = inPhase.map((entry) => entry.id)
31
+ doc.entryEvidenceEvents = inPhase.map((entry) => entry.eventId)
27
32
  }
28
33
  function budget(doc) {
29
34
  const remainingEstimatedHours = doc.status === 'complete' ? 0 : doc.phases.slice(doc.current).reduce((sum, phase) => sum + phase.estimatedHours, 0)
@@ -75,7 +80,12 @@ export function createCurriculumStore({ dataDir, loadMap, loadProgress, loadSche
75
80
  const temporary = `${path}.${randomUUID()}.tmp`
76
81
  try {
77
82
  writeFileSync(temporary, '# 课程路线;进度与检测证据仍以 PROGRESS.yaml 为准。\n' + stringify(doc, { lineWidth: 0 }), 'utf8')
78
- renameSync(temporary, path)
83
+ try { renameSync(temporary, path) } catch (err) {
84
+ if (!['EPERM', 'EEXIST'].includes(err.code)) throw err
85
+ try { unlinkSync(path) } catch (removeErr) { if (removeErr.code !== 'ENOENT') throw err }
86
+ copyFileSync(temporary, path)
87
+ unlinkSync(temporary)
88
+ }
79
89
  } finally {
80
90
  try { unlinkSync(temporary) } catch (err) { if (err.code !== 'ENOENT') throw err }
81
91
  }
@@ -84,7 +94,7 @@ export function createCurriculumStore({ dataDir, loadMap, loadProgress, loadSche
84
94
  const empty = {
85
95
  ok: true, reject: '', exists: false, revision: 0, status: 'none', goal: '', baseline: '',
86
96
  weeklyHours: 0, deadline: '', phaseId: '', phaseTitle: '', outcome: '', exitCriteria: '',
87
- focusNodes: [], focusUntil: '', focusExpired: false, requiredVerified: [], missingVerified: [],
97
+ focusNodes: [], phaseCompetencies: [], focusUntil: '', focusExpired: false, requiredVerified: [], missingVerified: [],
88
98
  pendingNodes: [], roadmap: [], history: [], updated: '',
89
99
  needsReview: false, planningWarnings: [], evidence: [], evidenceCount: 0, evidenceTruncated: false,
90
100
  missingPrerequisites: [], missingFreshEvidence: [],
@@ -103,6 +113,7 @@ export function createCurriculumStore({ dataDir, loadMap, loadProgress, loadSche
103
113
  baseline: doc.baseline, weeklyHours: doc.weeklyHours, deadline: doc.deadline,
104
114
  phaseId: phase.id, phaseTitle: phase.title, outcome: phase.outcome, exitCriteria: phase.exitCriteria,
105
115
  focusNodes: doc.focusNodes.length ? doc.focusNodes : phase.nodes,
116
+ phaseCompetencies: phase.competencies,
106
117
  focusUntil: doc.focusUntil, focusExpired: Boolean(doc.focusUntil && doc.focusUntil < dateOf(now)),
107
118
  requiredVerified: phase.requiredVerified,
108
119
  missingVerified: phase.requiredVerified.filter((n) => progress.nodes?.[n]?.status !== 'verified'),
@@ -112,9 +123,9 @@ export function createCurriculumStore({ dataDir, loadMap, loadProgress, loadSche
112
123
  history: doc.history.map(({ revision, action, reason, at, fromPhase, toPhase }) => ({ revision, action, reason, at, fromPhase, toPhase })),
113
124
  updated: doc.updated,
114
125
  needsReview: planningWarnings.length > 0, planningWarnings,
115
- evidence: catalog.filter((entry) => phase.nodes.includes(entry.node)).slice(-200),
116
- evidenceCount: catalog.filter((entry) => phase.nodes.includes(entry.node)).length,
117
- evidenceTruncated: catalog.filter((entry) => phase.nodes.includes(entry.node)).length > 200,
126
+ evidence: catalog.filter((entry) => phase.nodes.includes(entry.node) || phase.competencies.includes(entry.competency)).slice(-200),
127
+ evidenceCount: catalog.filter((entry) => phase.nodes.includes(entry.node) || phase.competencies.includes(entry.competency)).length,
128
+ evidenceTruncated: catalog.filter((entry) => phase.nodes.includes(entry.node) || phase.competencies.includes(entry.competency)).length > 200,
118
129
  missingPrerequisites,
119
130
  missingFreshEvidence: doc.version === 2 ? missingFreshEvidence(phase, catalog, doc.entryEvidenceRefs, dateOf(() => new Date(doc.enteredAt)), doc.entryEvidenceEvents) : [...phase.nodes],
120
131
  ...budget(doc),
@@ -180,7 +191,7 @@ export function createCurriculumStore({ dataDir, loadMap, loadProgress, loadSche
180
191
  throw new Error('当前诊断不能通过 revise 改成训练或删掉待诊断节点;先产生新记录,再 advance。')
181
192
  }
182
193
  doc.phases = phases
183
- const workKeys = ['kind', 'nodes', 'outcome', 'exitCriteria', 'basis', 'finding', 'evidenceRefs']
194
+ const workKeys = ['kind', 'nodes', 'competencies', 'outcome', 'exitCriteria', 'basis', 'finding', 'evidenceRefs']
184
195
  if (old.version === 1 || workKeys.some((key) => JSON.stringify(doc.phases[doc.current][key]) !== JSON.stringify(old.phases[old.current][key]))) enter(doc, catalog)
185
196
  }
186
197
  }
@@ -237,7 +248,8 @@ export function createCurriculumStore({ dataDir, loadMap, loadProgress, loadSche
237
248
  return [
238
249
  `- 长期目标:${state.goal};课程版本 ${state.revision}(${state.status})`,
239
250
  `- 当前阶段:${state.phaseTitle}(${state.phaseId});能力成果:${state.outcome}`,
240
- `- 主线节点:${state.focusNodes.join('、')}`,
251
+ `- 主线节点:${state.focusNodes.join('、') || '由比赛能力证据驱动'}`,
252
+ state.phaseCompetencies.length ? `- 阶段比赛能力:${state.phaseCompetencies.join('、')}` : '',
241
253
  `- 阶段验收:${state.exitCriteria}`,
242
254
  `- 阶段编排依据:${state.roadmap.find((phase) => phase.active)?.basis ?? ''}`,
243
255
  ...state.planningWarnings.map((warning) => `- 编排待核查:${warning}`),
@@ -255,7 +267,7 @@ export function createCurriculumStore({ dataDir, loadMap, loadProgress, loadSche
255
267
  const stringList = { type: 'array', items: { type: 'string' } }
256
268
  const phaseProperties = {
257
269
  id: { type: 'string', required: true }, title: { type: 'string', required: true },
258
- outcome: { type: 'string', required: true }, nodes: { ...stringList, required: true },
270
+ outcome: { type: 'string', required: true }, nodes: stringList, competencies: stringList,
259
271
  exitCriteria: { type: 'string', required: true }, requiredVerified: stringList,
260
272
  kind: { type: 'string', required: true, enum: ['diagnostic', 'training', 'provisional', 'legacy'], description: '诊断/有证据训练/远期待定;legacy只用于保留已完成历史。' },
261
273
  finding: { type: 'string', required: true, enum: ['gap', 'extension', 'unknown'] },
@@ -271,14 +283,14 @@ export function curriculumTool(store) {
271
283
  const required = (type) => ({ type, required: true })
272
284
  return {
273
285
  name: 'coach_curriculum',
274
- description: '按真实记录编排长期课程:assess取检测/判因/训练的证据id;read查看路线;create建立;revise按证据调整;advance验收后推进。训练阶段必须逐节点有真实短板或进阶起点,并按显式前置与必验阶段排序;无依据先diagnostic,远期可provisional但不能直接执行。阶段需目标贡献、优先级理由、时间估算。写操作带expectedRevision和reason;诊断/训练验收都需进入阶段后的新事件,不能复用旧记录。',
286
+ description: '按真实记录编排长期课程:assess取检测/判因/训练/VP的证据id;read查看路线;create建立;revise按证据调整;advance验收后推进。阶段可绑定地图节点,也可只绑定比赛能力(读题、决策、团队沟通);训练阶段必须有对应真实短板或进阶起点,并按显式前置与必验阶段排序;无依据先diagnostic,远期可provisional但不能直接执行。阶段需目标贡献、优先级理由、时间估算。写操作带expectedRevision和reason;诊断/训练验收都需进入阶段后的新事件,不能复用旧记录。',
275
287
  parameters: {
276
288
  action: { type: 'string', required: true, enum: ['assess', 'read', 'create', 'revise', 'advance'] },
277
289
  evidenceNodes: { ...stringList, description: 'assess可按地图节点过滤;最多返回200条,截断时缩小节点范围。' },
278
290
  expectedRevision: { type: 'integer', description: 'read 返回的版本;首次 create 为 0。' },
279
291
  goal: { type: 'string', description: '长期能力目标。' }, baseline: { type: 'string', description: '已核实的起点和待确认信息,不能猜测。' },
280
292
  weeklyHours: { type: 'number', description: '每周可用小时;0 表示未确认。' }, deadline: { type: 'string', description: '目标日期 YYYY-MM-DD,可留空。' },
281
- phases: { type: 'array', description: '按证据与前置排序;training所有节点必验;diagnostic收集新记录;provisional保留远期假设。', items: { type: 'object', additionalProperties: false, properties: phaseProperties } },
293
+ phases: { type: 'array', description: '按证据与前置排序;阶段至少绑定一个地图节点或比赛能力;training地图节点必验、能力阶段用新 VP/复盘证据验收;diagnostic收集新记录;provisional保留远期假设。', items: { type: 'object', additionalProperties: false, properties: phaseProperties } },
282
294
  focusNodes: { ...stringList, description: '当前阶段内的本周主线,空数组表示沿用阶段节点。' },
283
295
  focusUntil: { type: 'string', description: '本周主线截止日期 YYYY-MM-DD,可留空;到期先复盘。' },
284
296
  reason: { type: 'string', description: '建立/调整的依据;advance 时写验收证据及记录来源,至少 10 字。' },
@@ -289,16 +301,16 @@ export function curriculumTool(store) {
289
301
  status: required('string'), goal: required('string'), baseline: required('string'), weeklyHours: required('number'),
290
302
  deadline: required('string'), phaseId: required('string'), phaseTitle: required('string'), outcome: required('string'),
291
303
  exitCriteria: required('string'), focusUntil: required('string'), focusExpired: required('boolean'), updated: required('string'),
292
- focusNodes: { ...stringList, required: true }, requiredVerified: { ...stringList, required: true },
304
+ focusNodes: { ...stringList, required: true }, phaseCompetencies: { ...stringList, required: true }, requiredVerified: { ...stringList, required: true },
293
305
  missingVerified: { ...stringList, required: true }, pendingNodes: { ...stringList, required: true },
294
306
  needsReview: required('boolean'), planningWarnings: { ...stringList, required: true },
295
307
  missingPrerequisites: { ...stringList, required: true }, missingFreshEvidence: { ...stringList, required: true },
296
308
  evidenceCount: required('integer'), evidenceTruncated: required('boolean'),
297
309
  remainingEstimatedHours: required('number'), estimatedWeeks: required('number'), budgetWarnings: { ...stringList, required: true },
298
310
  evidence: { type: 'array', required: true, items: { type: 'object', additionalProperties: false, properties:
299
- Object.fromEntries(['id', 'eventId', 'source', 'node', 'date', 'signal', 'summary', 'fingerprint'].map((key) => [key, required('string')])) } },
311
+ { ...Object.fromEntries(['id', 'eventId', 'source', 'node', 'date', 'signal', 'summary', 'fingerprint'].map((key) => [key, required('string')])), competency: { type: 'string' } } } },
300
312
  roadmap: { type: 'array', required: true, items: { type: 'object', additionalProperties: false, properties: {
301
- ...phaseProperties, requiredVerified: { ...stringList, required: true }, position: required('integer'), active: required('boolean'),
313
+ ...phaseProperties, nodes: { ...stringList, required: true }, competencies: { ...stringList, required: true }, requiredVerified: { ...stringList, required: true }, position: required('integer'), active: required('boolean'),
302
314
  prerequisites: { ...stringList, required: true },
303
315
  } } },
304
316
  history: { type: 'array', required: true, items: { type: 'object', additionalProperties: false, properties: {
@@ -308,14 +320,14 @@ export function curriculumTool(store) {
308
320
  } },
309
321
  render: (args, state) => [{ type: 'text', text: !state.ok ? `❌ 课程未更新:${state.reject}`
310
322
  : args?.action === 'assess' ? ['【实际编排依据】',
311
- ...state.evidence.map((entry) => `${entry.id}\n ${entry.date} ${entry.node} · ${entry.signal} · ${entry.summary}`),
323
+ ...state.evidence.map((entry) => `${entry.id}\n ${entry.date} ${entry.node || entry.competency} · ${entry.signal} · ${entry.summary}`),
312
324
  state.evidenceCount ? `共${state.evidenceCount}条${state.evidenceTruncated ? ',仅返回最后200条;按evidenceNodes缩小范围' : ''}` : '没有可用记录。先建立诊断阶段,不凭rating或状态断言短板。',
313
325
  ...state.planningWarnings,
314
326
  ].join('\n')
315
327
  : !state.exists ? '尚无长期课程。先assess核查真实记录;缺依据先diagnostic,未来假设用provisional。'
316
328
  : [`【长期课程 · 版本 ${state.revision} · ${state.status}】${state.goal}`,
317
329
  ...state.roadmap.map((p) => `${p.active ? '▶ ' : ''}${p.position}. ${p.title} [${p.kind}]:${p.outcome}\n 依据:${p.basis}\n 排序:${p.priorityReason};预计${p.estimatedHours}小时`),
318
- `当前主线:${state.focusNodes.join('、')}`, `验收:${state.exitCriteria}`,
330
+ `当前主线:${state.focusNodes.join('、') || '能力证据驱动'}`, state.phaseCompetencies.length ? `阶段比赛能力:${state.phaseCompetencies.join('、')}` : '', `验收:${state.exitCriteria}`,
319
331
  state.missingVerified.length ? `必验缺口:${state.missingVerified.join('、')}` : '必验节点已有记录;仍需判断能力成果是否达到,不能自动推进。',
320
332
  state.pendingNodes.length ? `先完成当前单元:${state.pendingNodes.join('、')}` : '',
321
333
  state.focusExpired ? '本周主线到期,先复盘并修订。' : '',