acmer-coach 1.2.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -20,7 +20,7 @@
20
20
 
21
21
  ## 它到底做了什么
22
22
 
23
- 21 个工具,两条轴:
23
+ 26 个工具,三条轴:知识图、比赛能力、训练证据。
24
24
 
25
25
  | 轴 | 含义 |
26
26
  |---|---|
@@ -45,10 +45,33 @@ coach_log 过了 → 记一条,然后夸
45
45
  coach_pool → coach_test → coach_grade 章节检测,过了才标「已验证」
46
46
  ↓
47
47
  coach_schedule → coach_plan 排课
48
+ ↓
49
+ coach_target → coach_vp_import → coach_postmortem → coach_focus
50
+ ↓ 比赛目标、VP 复盘、当前重点
51
+ coach_scope 适用范围、延伸、反例、退出条件
48
52
  ```
49
53
 
50
54
  `coach_wiki` 是知识来源:讲任何知识点之前先查本地 OI Wiki,**别凭记忆讲**。
51
55
 
56
+ ### 目标驱动训练
57
+
58
+ 知识图只回答“有哪些知识点”,不回答“为什么现在学它”。先用
59
+ `coach_target` 设置比赛、日期、目标结果、团队模式、可用时间和能力优先级,
60
+ 再用 `coach_vp_import` 导入 VP,`coach_postmortem` 写复盘,`coach_focus` 计算当前
61
+ 训练重点。每次布置都要能说清楚它服务哪个比赛能力、适用范围、迁移题和退出条件。
62
+
63
+ VP 记录格式是 JSON 数组,每场至少包含 `eventId`、`contest`、`date` 和 `problems`:
64
+
65
+ ```json
66
+ [{"eventId":"vp-西安-01","contest":"区域赛 VP","date":"2026-10-10",
67
+ "durationMinutes":300,"teamMode":"team","problems":[
68
+ {"problemId":"A","status":"AC","readMinutes":8,"solveMinutes":22,
69
+ "attempts":1,"competencies":["contest-reading"]}]}]
70
+ ```
71
+
72
+ `coach_scope` 会返回一个能力或知识点的适用范围、延伸、反例、常见错误、迁移题
73
+ 和退出条件。没有人工范围卡片时它会明确拒绝编造,避免把“讲过”误当成“会用”。
74
+
52
75
  ### 长期学习路线
53
76
 
54
77
  `coach_curriculum assess` 从已有检测、代码判因、做题记录和日程实际完成中列出
@@ -70,14 +93,15 @@ learned/verified 记录,或路线中在此前安排的必验训练,不能从
70
93
  路线按剩余 `estimatedHours`、每周预算和目标日期提示容量不足或估算缺失;这只是
71
94
  调整范围的提示,不会把周平均当成精确日程,也不阻止执行。
72
95
 
73
- 每轮注入长期目标、当前阶段和未完成单元。`coach_assign` 带课程版本,普通训练
96
+ 每轮注入比赛目标、能力重点、长期阶段和未完成单元。`coach_assign` 带课程版本,普通训练
74
97
  限于当前主线;补漏/复习需声明目的和返回依据。完成记录仍归原进度文件,
75
98
  不会因一题 AC 或日历到期自动跳阶段。没有建立课程的用户保持原有工具行为。
76
99
 
77
100
  本周主线可设置 `focusUntil`,到期先复盘修订。路线不提前占用数月日程,
78
101
  `coach_plan` 继续管理近期时间块。旧 v1 路线可读,会标记 `needsReview`;需明确
79
102
  修订当前及后续阶段后迁移 v2,插件不会自动重写。优先级与教学方案仍由模型判断,
80
- 程序只检查证据、前置和验收边界。本功能没有新增 VP 自动读取或后台周报。
103
+ 程序现在会要求目标和 VP 证据先进入策略层;能力卡片仍由仓库维护,未知卡片不会自动臆造。
104
+ 这版仍不包含后台定时同步和自动读取第三方 VP 网站,导出后用 `coach_vp_import` 导入。
81
105
  详见 [长期课程机制](docs/长期课程机制.md)。
82
106
 
83
107
  **「学过」和「已验证」是两级,绝不合并。** 自评 ≠ 掌握 ——
@@ -0,0 +1,92 @@
1
+ version: 1
2
+ competencies:
3
+ - id: contest-reading
4
+ title: 比赛读题与筛选
5
+ contestUse: 在前 30 分钟扫完题面,识别可做题和高风险题,避免漏读与过早开写。
6
+ nodes: []
7
+ appliesTo: [所有区域赛场次]
8
+ extensions: [首轮扫描, 题目分层, 读题时间盒]
9
+ notFor: [把 A 题默认当成签到题, 读一题写一题]
10
+ commonFailures: [漏读题面, 被样例带偏, 没有放弃条件]
11
+ transferProblems: [一场完整 VP]
12
+ exitEvidence: [两场 VP 都记录首轮扫描顺序, 没有漏读题]
13
+ - id: contest-decision
14
+ title: 比赛决策与终局
15
+ contestUse: 根据成功概率、剩余时间和罚时决定继续、换题或集中火力。
16
+ nodes: []
17
+ appliesTo: [区域赛 VP, 最后 60 分钟]
18
+ extensions: [换题阈值, 终局集中, 代码验算分工]
19
+ notFor: [多道难题同时无边界死磕]
20
+ commonFailures: [沉没成本, 最后阶段分散火力, 不复盘换题原因]
21
+ transferProblems: [5 小时团队 VP]
22
+ exitEvidence: [两场 VP 有明确换题记录和终局策略]
23
+ - id: implementation-stability
24
+ title: 实现稳定性
25
+ contestUse: 在限时下把已知套路写成一次通过、边界完整的代码。
26
+ nodes: [数据结构, 二分, 前缀和, 差分, 动态规划基础]
27
+ appliesTo: [中档签到题, 需要快速转化的常见模型]
28
+ extensions: [边界清单, 小样例自测, 重写压缩]
29
+ notFor: [只背模板不理解状态来源]
30
+ commonFailures: [下标错误, 初始化遗漏, 重复提交同一类低级错误]
31
+ transferProblems: [两道未见过的同类题]
32
+ exitEvidence: [两道迁移题限时独立通过, 至少一题隔天复测]
33
+ - id: dp-modeling
34
+ title: DP 建模与迁移
35
+ contestUse: 从题面抽出状态、转移、顺序和复杂度,而不是只认题型名称。
36
+ nodes: [动态规划基础, 区间 DP, 背包 DP, 树形 DP, 状压 DP, 数位 DP]
37
+ appliesTo: [子结构可复用, 状态规模可控, 需要枚举决策顺序]
38
+ extensions: [区间状态, 集合状态, 树上状态, 优化转移]
39
+ notFor: [状态包含不可压缩的全局历史]
40
+ commonFailures: [状态定义含糊, 转移漏情况, 复杂度算错]
41
+ transferProblems: [一道结构相同但题面不同的 DP, 一道隔天迁移题]
42
+ exitEvidence: [能解释状态为何足够, 两道新题独立完成]
43
+ - id: graph-modeling
44
+ title: 图论建模
45
+ contestUse: 识别图结构并选对最短路、连通性、拓扑序、树或网络流模型。
46
+ nodes: [图论基础, 最短路, 拓扑排序, DFS(搜索), 并查集]
47
+ appliesTo: [关系传播, 依赖顺序, 连通块, 路径代价]
48
+ extensions: [DAG DP, 树上问题, 生成树, 流与匹配]
49
+ notFor: [把所有关系题都硬套最短路]
50
+ commonFailures: [建边方向错, 忘记不连通, 复杂度超限]
51
+ transferProblems: [两种表面不同的关系建模题]
52
+ exitEvidence: [限时识别模型并独立写出两题]
53
+ - id: data-structures
54
+ title: 数据结构选型与实现
55
+ contestUse: 根据操作、约束和查询方式选择合适结构并稳定实现。
56
+ nodes: [树状数组, 线段树, 单调队列, 单调栈, 树链剖分]
57
+ appliesTo: [动态维护, 区间查询, 单调性优化]
58
+ extensions: [懒标记, 可持久化, 合并技巧, 复杂度降阶]
59
+ notFor: [数据结构复杂度超过题目实际需求]
60
+ commonFailures: [区间边界错, 懒标记下传错, 结构选重]
61
+ transferProblems: [同结构不同操作题]
62
+ exitEvidence: [模板能从零写出, 新题能说明为何选它]
63
+ - id: math-combinatorics
64
+ title: 数学与组合建模
65
+ contestUse: 把计数、模运算、概率和构造题压成可计算的结构。
66
+ nodes: [排列组合, 数论基础, 概率论, 博弈论]
67
+ appliesTo: [计数, 期望, 模意义运算, 不变量]
68
+ extensions: [容斥, 生成函数, 矩阵快速幂, 组合恒等式]
69
+ notFor: [只凭样例猜规律]
70
+ commonFailures: [重复计数, 模逆不存在, 边界概率遗漏]
71
+ transferProblems: [两道不同叙事的计数题]
72
+ exitEvidence: [能写出计数对象和去重方式, 一题隔天复测]
73
+ - id: code-review
74
+ title: 代码检查与调试
75
+ contestUse: 在提交前快速发现边界、溢出、复杂度和实现偏差。
76
+ nodes: [枚举, 递归 & 分治, 动态规划基础]
77
+ appliesTo: [所有需要限时提交的题]
78
+ extensions: [反例构造, 对拍思维, 小样例清单]
79
+ notFor: [盲目重写整份代码]
80
+ commonFailures: [没有最小反例, 只看样例, 不验证复杂度]
81
+ transferProblems: [给出一份错误代码并定位最小反例]
82
+ exitEvidence: [三次提交前检查都能指出具体风险]
83
+ - id: team-communication
84
+ title: 团队沟通与分工
85
+ contestUse: 让三个人并行推进、及时同步、减少重复劳动和信息丢失。
86
+ nodes: []
87
+ appliesTo: [团队 VP, 区域赛现场]
88
+ extensions: [口头题解格式, 代码交接, 验算角色, 决策记录]
89
+ notFor: [三个人长期各做各的]
90
+ commonFailures: [没人汇报, 两人重复做题, 交接时丢约束]
91
+ transferProblems: [三人 5 小时 VP]
92
+ exitEvidence: [两场 VP 有分工记录和交接记录]
@@ -7,6 +7,20 @@
7
7
 
8
8
  ## 教练职责(核心)
9
9
 
10
+ ### 比赛目标先于知识图
11
+
12
+ * 有比赛目标时,先调 `coach_target read`,再调 `coach_focus`。没有目标时不能用
13
+ rating、游标或地图后继猜长期方向;先建立比赛、日期、结果目标、团队模式、时间预算
14
+ 和能力优先级。
15
+ * `coach_focus` 给的是比赛能力重点,不是知识点清单。每次训练必须说明它服务哪个能力、
16
+ 为什么现在排、暂缓什么,以及用什么表现退出。知识图只负责把能力重点展开成可学节点。
17
+ * 有 VP 时先导入 `coach_vp_import`,赛后调 `coach_postmortem`。VP 的读题、耗时、提交、
18
+ 换题和团队问题优先于 rating;没有 VP 证据时要明确说“尚无记录”,不能装作知道短板。
19
+ * 讲知识点之前调 `coach_scope`。必须说明适用范围、延伸、反例、常见错误、迁移题和退出
20
+ 条件;工具返回“没有范围卡片”时不许凭记忆补一段看似完整的范围。
21
+ * 训练重点是比赛能力,不是沿着地图无限往下走。阶段达到目标后可以停在当前节点,转做
22
+ 迁移题、限时题、VP 或复盘;后继节点只有在它服务当前能力目标时才进入。
23
+
10
24
  ### 长期课程先于每日选点
11
25
 
12
26
  * 长期训练先用 `coach_curriculum read` 查看路线,再用 `assess` 查实际编排依据。
@@ -51,7 +65,8 @@
51
65
 
52
66
  下面九条是硬规则,每条都能机械检查。违反就是没做好本职工作:
53
67
 
54
- 1. **给训练指令前,必须先调 `coach_next`**。不许凭印象挑知识点 ——
68
+ 1. **给训练指令前,必须先调 `coach_target`/`coach_focus`,再调 `coach_next`**。
69
+ 不许只凭 rating 或地图后继挑知识点 ——
55
70
  它可能在地图里根本不存在,或者前置还没满足。
56
71
  2. **每条指令必须带地图节点 id**。节点 id 只能从地图取到,
57
72
  所以这条会自动逼你先读地图。
package/dev/run-all.sh CHANGED
@@ -8,6 +8,7 @@
8
8
  # setup-check 冷启动(离线)
9
9
  # curriculum-check 长期课程、版本、阶段验收与任务关联
10
10
  # curriculum-evidence 编排证据目录、引用与事件身份
11
+ # strategy-check 比赛目标、VP 证据、复盘与重点选择
11
12
  # parity-check 和线上那份的保真度对比
12
13
  #
13
14
  # 主验收跑的是**已安装副本**(不是源码)—— 那才是运行时真正加载的东西。
@@ -63,6 +64,7 @@ run "wiki-check" node dev/wiki-check.mjs
63
64
  run "setup-check" node dev/setup-check.mjs
64
65
  run "curriculum" node --test dev/curriculum-check.mjs
65
66
  run "evidence" node --test dev/curriculum-evidence-check.mjs
67
+ run "strategy" node --test dev/strategy-check.mjs
66
68
  if [ "${COACH_LIVE_INDEX+x}" = x ] || node -e "
67
69
  const fs=require('fs'), os=require('os'), path=require('path');
68
70
  process.exit(fs.existsSync(path.join(os.homedir(), '.dsh', 'profiles', 'web', 'node_modules', 'dsh-coach', 'index.js')) ? 0 : 1);
@@ -0,0 +1,75 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { mkdtempSync, rmSync, existsSync } from 'node:fs'
4
+ import { join } from 'node:path'
5
+ import { createStrategyStore } from '../lib/strategy.js'
6
+
7
+ function fixture() {
8
+ const dir = mkdtempSync(join(process.cwd(), '.strategy-test-'))
9
+ const store = createStrategyStore({
10
+ dataDir: dir,
11
+ competenciesPath: join(process.cwd(), 'assets', 'knowledge', 'COMPETENCIES.yaml'),
12
+ })
13
+ return { dir, store }
14
+ }
15
+
16
+ test('target is explicit and revision protected', () => {
17
+ const { dir, store } = fixture()
18
+ try {
19
+ assert.equal(store.target('read').revision, 0)
20
+ const saved = store.target('set', {
21
+ expectedRevision: 0, contest: '西安区域赛', date: '2026-10-18', result: '区域赛金牌',
22
+ teamMode: 'team', weeklyHours: 35,
23
+ constraints: { weekday: 5, saturday: 10, sunday: 0 },
24
+ priorities: [{ id: 'contest-reading', weight: 100, reason: '区域赛前要先稳定读题' }],
25
+ })
26
+ assert.equal(saved.ok, true)
27
+ assert.equal(saved.revision, 1)
28
+ assert.equal(store.target('set', { expectedRevision: 0 }).ok, false)
29
+ assert.equal(existsSync(join(dir, 'TARGET.yaml')), true)
30
+ } finally { rmSync(dir, { recursive: true, force: true }) }
31
+ })
32
+
33
+ test('VP import is idempotent and postmortem drives focus', () => {
34
+ const { dir, store } = fixture()
35
+ try {
36
+ store.target('set', {
37
+ expectedRevision: 0, contest: '西安区域赛', date: '2026-10-18', result: '区域赛金牌',
38
+ teamMode: 'team', weeklyHours: 35,
39
+ priorities: [
40
+ { id: 'contest-reading', weight: 80 },
41
+ { id: 'dp-modeling', weight: 70 },
42
+ ],
43
+ })
44
+ const event = { eventId: 'vp-1', contest: '训练 VP', date: '2026-10-10', durationMinutes: 300,
45
+ problems: [{ problemId: 'A', status: 'AC', solveMinutes: 20, competencies: ['contest-reading'] }] }
46
+ assert.equal(store.vpImport([event]).total, 1)
47
+ assert.equal(store.vpImport([event]).total, 1)
48
+ assert.equal(store.postmortem({ eventId: 'vp-1', competencies: ['contest-reading'], rootCauses: ['扫题不完整'], nextActions: ['固定首轮扫描'] }).ok, true)
49
+ const focus = store.focus()
50
+ assert.equal(focus.ok, true)
51
+ assert.equal(focus.focus[0].id, 'contest-reading')
52
+ } finally { rmSync(dir, { recursive: true, force: true }) }
53
+ })
54
+
55
+ test('scope refuses unknown knowledge instead of inventing applicability', () => {
56
+ const { dir, store } = fixture()
57
+ try {
58
+ const v = store.scope('不存在的知识点')
59
+ assert.equal(v.ok, false)
60
+ assert.equal(v.found, false)
61
+ } finally { rmSync(dir, { recursive: true, force: true }) }
62
+ })
63
+
64
+ test('first-use focus has a complete refusal shape and targets reject unknown cards', () => {
65
+ const { dir, store } = fixture()
66
+ try {
67
+ const empty = store.focus()
68
+ assert.deepEqual(Object.keys(empty).sort(), ['contest', 'deadline', 'evidenceEvents', 'focus', 'ok', 'postmortems', 'postponed', 'reject'])
69
+ const rejected = store.target('set', {
70
+ expectedRevision: 0, contest: '西安区域赛', date: '2026-10-18', result: '区域赛金牌',
71
+ weeklyHours: 35, priorities: [{ id: 'invented-card', weight: 100 }],
72
+ })
73
+ assert.equal(rejected.ok, false)
74
+ } finally { rmSync(dir, { recursive: true, force: true }) }
75
+ })
package/dev.sh CHANGED
@@ -35,7 +35,7 @@ done
35
35
  # 从副本目录跑 `bash dev.sh` 走的是另一条路(`dsh plugin remove/add`,而那条
36
36
  # 路不可靠),和这份的行为不一样。换句话说,**同一个命令,从哪个目录按回车
37
37
  # 决定跑哪份脚本** —— 两份 dev.sh 就是两份真相,正是这份文件在防的那种坑。
38
- SYNC_FILES='index.js package.json verify.mjs verify-shape.mjs progress-cli.mjs build-node-meta.mjs cordis.patch.yml README.md lib/client.js lib/setup.js lib/curriculum.js lib/curriculum-evidence.js lib/curriculum-planning.js assets/rules/coach-rules.md dev/curriculum-check.mjs dev/curriculum-evidence-check.mjs docs/长期课程机制.md dev.sh'
38
+ SYNC_FILES='index.js package.json verify.mjs verify-shape.mjs progress-cli.mjs build-node-meta.mjs cordis.patch.yml README.md lib/client.js lib/setup.js lib/curriculum.js lib/curriculum-evidence.js lib/curriculum-planning.js lib/strategy.js assets/knowledge/COMPETENCIES.yaml assets/rules/coach-rules.md dev/curriculum-check.mjs dev/curriculum-evidence-check.mjs dev/strategy-check.mjs docs/长期课程机制.md dev.sh'
39
39
  STALE=''
40
40
  for f in $SYNC_FILES; do
41
41
  for d in "${DSTS[@]}"; do
package/index.js CHANGED
@@ -30,11 +30,12 @@ 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.0.0'
38
39
 
39
40
  // 块结构(方针 §4.1.1):40 自己做 + 10 解决遗留 + 10 重写 = 60 分钟。
40
41
  // 这是**一个题目循环**的固定形状。时间不够该换更小的题,不是把块压扁。
@@ -71,6 +72,10 @@ 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
74
  const curriculum = createCurriculumStore({ dataDir: DATA_DIR, loadMap, loadProgress, loadSchedule })
75
+ const strategy = createStrategyStore({
76
+ dataDir: DATA_DIR,
77
+ competenciesPath: join(import.meta.dirname, 'assets', 'knowledge', 'COMPETENCIES.yaml'),
78
+ })
74
79
 
75
80
  // 探针要的文件,各自代表一条后面的依赖:
76
81
  // MAP.yaml 地图(coach_next 读它算候选)
@@ -1602,12 +1607,15 @@ function registerStateHook(ctx) {
1602
1607
  // 课程可以先于游标存在;即使尚未布置动作,也要在新会话继续同一路线。
1603
1608
  let hasCurriculum = false
1604
1609
  try { hasCurriculum = Boolean(curriculum.load()) } catch { hasCurriculum = true }
1605
- if (!cursor && !pending && !studying.length && !hasCurriculum) return decision
1610
+ let hasTarget = false
1611
+ try { hasTarget = Boolean(strategy.loadTarget().contest) } catch { hasTarget = true }
1612
+ if (!cursor && !pending && !studying.length && !hasCurriculum && !hasTarget) return decision
1606
1613
 
1607
1614
  const { byId } = loadMap()
1608
1615
  const nameOf = (id) => byId.get(String(id))?.name ?? String(id)
1609
1616
  const node = byId.get(cursor)
1610
1617
  const lines = ['<coach_state>', '(教练插件注入的位置,不用再调工具问)']
1618
+ lines.push(strategy.context())
1611
1619
  lines.push(curriculum.context())
1612
1620
  lines.push(cursor
1613
1621
  ? `- 游标:${nameOf(cursor)}(${cursor})${node?.domain ? ` [${node.domain}]` : ''}`
@@ -1813,6 +1821,116 @@ function apply(ctx) {
1813
1821
 
1814
1822
  ctx.tools.register(defineTool(curriculumTool(curriculum)))
1815
1823
 
1824
+ // ── 目标驱动策略层:比赛目标 / VP 证据 / 复盘 / 当前重点 ─────────
1825
+ // 课程图回答“有哪些知识点”,策略层回答“为什么现在学这个”。两者分开,
1826
+ // 避免把 rating 或游标误当成区域赛目标。
1827
+ const strategyText = (v) => [{ type: 'text', text: v.ok ? v.text : `❌ ${v.reject}` }]
1828
+ ctx.tools.register(defineTool({
1829
+ name: 'coach_target',
1830
+ description: '设置或读取比赛目标。目标先于知识点:contest/date/result/teamMode/weeklyHours/priorities 都确定后,教练才有依据选训练重点。',
1831
+ parameters: {
1832
+ action: { type: 'string', required: true, enum: ['read', 'set', 'clear'] },
1833
+ expectedRevision: { type: 'integer', description: 'read 返回的 revision;首次 set 用 0。' },
1834
+ contest: { type: 'string', description: '比赛名称,例如西安区域赛' },
1835
+ date: { type: 'string', description: '比赛日期 YYYY-MM-DD' },
1836
+ result: { type: 'string', description: '目标结果,例如区域赛金牌' },
1837
+ teamMode: { type: 'string', description: 'solo 或 team' },
1838
+ weeklyHours: { type: 'number', description: '每周可用训练小时' },
1839
+ constraints: { type: 'object', additionalProperties: true, description: '可选时间约束,例如 weekday/saturday/sunday' },
1840
+ priorities: { type: 'array', description: '能力优先级 [{id, weight, reason}],weight 越大越优先', items: { type: 'object', additionalProperties: false, properties: {
1841
+ id: { type: 'string', required: true }, weight: { type: 'number', required: true }, reason: { type: 'string' },
1842
+ } } },
1843
+ },
1844
+ output: { schema: { type: 'object', additionalProperties: false, properties: {
1845
+ ok: { type: 'boolean', required: true }, reject: { type: 'string', required: true }, revision: { type: 'integer', required: true },
1846
+ contest: { type: 'string', required: true }, date: { type: 'string', required: true }, result: { type: 'string', required: true },
1847
+ teamMode: { type: 'string', required: true }, weeklyHours: { type: 'number', required: true }, priorities: { type: 'array', required: true },
1848
+ updated: { type: 'string', required: true }, version: { type: 'integer', required: true },
1849
+ constraints: { type: 'object', required: true, additionalProperties: true }, text: { type: 'string', required: true },
1850
+ } }, render: (_a, v) => strategyText(v) },
1851
+ execute: (args) => {
1852
+ const v = strategy.target(args?.action || 'read', args ?? {})
1853
+ return { ...v, text: v.ok
1854
+ ? `【比赛目标 · 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('、')}`
1855
+ : '' }
1856
+ },
1857
+ }))
1858
+
1859
+ ctx.tools.register(defineTool({
1860
+ name: 'coach_vp_import',
1861
+ description: '导入 VP 或模拟赛记录。它保存比赛过程证据,让教练根据读题、耗时、提交和题目选择复盘,不再只看 CF rating。重复 eventId 会更新,不会重复堆积。',
1862
+ parameters: {
1863
+ events: { type: 'array', required: true, items: { type: 'object', additionalProperties: true,
1864
+ description: '每场含 eventId/contest/date/problems;problem 含 problemId/status/readMinutes/solveMinutes/attempts/competencies' } },
1865
+ },
1866
+ output: { schema: { type: 'object', additionalProperties: false, properties: {
1867
+ ok: { type: 'boolean', required: true }, reject: { type: 'string', required: true }, imported: { type: 'integer', required: true },
1868
+ total: { type: 'integer', required: true }, revision: { type: 'integer', required: true }, text: { type: 'string', required: true },
1869
+ } }, render: (_a, v) => strategyText(v) },
1870
+ execute: (args) => {
1871
+ const v = strategy.vpImport(Array.isArray(args?.events) ? args.events : [])
1872
+ return { ok: v.ok, reject: v.reject, imported: v.imported, total: v.total, revision: v.revision,
1873
+ text: v.ok ? `✓ VP 记录已保存:本次 ${v.imported} 场,累计 ${v.total} 场,revision ${v.revision}` : '' }
1874
+ },
1875
+ }))
1876
+
1877
+ ctx.tools.register(defineTool({
1878
+ name: 'coach_postmortem',
1879
+ description: '记录一场 VP 的赛后复盘。至少写能力问题、根因或下一步动作之一;复盘会影响 coach_focus 的优先级。',
1880
+ parameters: {
1881
+ eventId: { type: 'string', required: true }, competencies: { type: 'array', items: { type: 'string' } },
1882
+ missedReads: { type: 'array', items: { type: 'string' } }, decisionErrors: { type: 'array', items: { type: 'string' } },
1883
+ teamIssues: { type: 'array', items: { type: 'string' } }, rootCauses: { type: 'array', items: { type: 'string' } },
1884
+ nextActions: { type: 'array', items: { type: 'string' } }, note: { type: 'string' }, date: { type: 'string' },
1885
+ },
1886
+ output: { schema: { type: 'object', additionalProperties: false, properties: {
1887
+ ok: { type: 'boolean', required: true }, reject: { type: 'string', required: true }, eventId: { type: 'string', required: true },
1888
+ revision: { type: 'integer', required: true }, postmortems: { type: 'array', required: true }, text: { type: 'string', required: true },
1889
+ } }, render: (_a, v) => strategyText(v) },
1890
+ execute: (args) => {
1891
+ const v = strategy.postmortem(args ?? {})
1892
+ return { ...v, text: v.ok ? `✓ 已记录 ${v.eventId} 的复盘;当前共 ${v.postmortems.length} 条复盘,revision ${v.revision}` : '' }
1893
+ },
1894
+ }))
1895
+
1896
+ ctx.tools.register(defineTool({
1897
+ name: 'coach_focus',
1898
+ description: '根据比赛目标权重和 VP 复盘证据生成当前训练重点。它输出能力、比赛价值、为什么现在学、关联知识点和暂缓项;没有目标时拒绝猜方向。',
1899
+ parameters: {},
1900
+ output: { schema: { type: 'object', additionalProperties: false, properties: {
1901
+ ok: { type: 'boolean', required: true }, reject: { type: 'string', required: true }, contest: { type: 'string', required: true },
1902
+ deadline: { type: 'string', required: true }, focus: { type: 'array', required: true }, postponed: { type: 'array', required: true },
1903
+ evidenceEvents: { type: 'integer', required: true }, postmortems: { type: 'integer', required: true }, text: { type: 'string', required: true },
1904
+ } }, render: (_a, v) => strategyText(v) },
1905
+ execute: () => {
1906
+ const v = strategy.focus()
1907
+ return { ...v, text: v.ok ? [`【当前训练重点】${v.contest} · 截止 ${v.deadline}`,
1908
+ ...v.focus.map((f, i) => `${i + 1}. ${f.title}(${f.id})\n 比赛价值:${f.contestUse}\n 为什么现在:${f.whyNow}\n 关联知识点:${f.nodes.join('、') || '需先定义'}\n 证据次数:${f.evidenceCount}`),
1909
+ v.postponed.length ? `暂缓:${v.postponed.join('、')}` : '没有暂缓项。',
1910
+ ].join('\n') : '' }
1911
+ },
1912
+ }))
1913
+
1914
+ ctx.tools.register(defineTool({
1915
+ name: 'coach_scope',
1916
+ description: '查看一个比赛能力或知识点的适用范围、延伸、反例、常见错误、迁移题和退出条件。没有范围卡片时明确拒绝编造。',
1917
+ parameters: { id: { type: 'string', required: true, description: '能力 id 或地图节点 id' } },
1918
+ output: { schema: { type: 'object', additionalProperties: false, properties: {
1919
+ ok: { type: 'boolean', required: true }, reject: { type: 'string', required: true }, found: { type: 'boolean', required: true },
1920
+ id: { type: 'string', required: true }, title: { type: 'string', required: true }, contestUse: { type: 'string', required: true },
1921
+ nodes: { type: 'array', required: true }, appliesTo: { type: 'array', required: true }, extensions: { type: 'array', required: true }, notFor: { type: 'array', required: true },
1922
+ commonFailures: { type: 'array', required: true }, transferProblems: { type: 'array', required: true }, exitEvidence: { type: 'array', required: true },
1923
+ text: { type: 'string', required: true },
1924
+ } }, render: (_a, v) => strategyText(v) },
1925
+ execute: (args) => {
1926
+ const v = strategy.scope(args?.id)
1927
+ return { ...v, text: v.ok ? [`【范围卡片】${v.title}(${v.id})`, `比赛价值:${v.contestUse}`,
1928
+ `适用:${v.appliesTo.join('、')}`, `延伸:${v.extensions.join('、')}`, `不要用于:${v.notFor.join('、')}`,
1929
+ `常见错误:${v.commonFailures.join('、')}`, `迁移:${v.transferProblems.join('、')}`, `退出条件:${v.exitEvidence.join('、')}`,
1930
+ ].join('\n') : '' }
1931
+ },
1932
+ }))
1933
+
1816
1934
  ctx.tools.register(defineTool({
1817
1935
  name: 'coach_setup',
1818
1936
  description:
@@ -0,0 +1,201 @@
1
+ // Goal-driven strategy layer for acmer-coach.
2
+ // It stores contest targets, VP evidence and postmortems separately from
3
+ // PROGRESS.yaml so the existing knowledge workflow keeps its meaning.
4
+ import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
5
+ import { join } from 'node:path'
6
+ import { parse, stringify } from 'yaml'
7
+
8
+ const clean = (value) => String(value ?? '').trim()
9
+ const asArray = (value) => Array.isArray(value) ? value : []
10
+ const isoDay = (value) => {
11
+ const day = clean(value)
12
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) return ''
13
+ const d = new Date(`${day}T12:00:00Z`)
14
+ return Number.isFinite(d.getTime()) && d.toISOString().slice(0, 10) === day ? day : ''
15
+ }
16
+
17
+ const emptyTarget = () => ({ version: 1, revision: 0, updated: '', contest: '', date: '', result: '', teamMode: 'solo', weeklyHours: 0, constraints: {}, priorities: [] })
18
+ const emptyVp = () => ({ version: 1, revision: 0, updated: '', events: [], postmortems: [] })
19
+
20
+ function readYaml(path, fallback) {
21
+ try {
22
+ const value = parse(readFileSync(path, 'utf8'))
23
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : fallback()
24
+ } catch (err) {
25
+ if (err.code === 'ENOENT') return fallback()
26
+ throw err
27
+ }
28
+ }
29
+
30
+ function writeYaml(path, header, value) {
31
+ const tmp = `${path}.${process.pid}.${Date.now()}.tmp`
32
+ writeFileSync(tmp, header + stringify(value, { lineWidth: 0 }), 'utf8')
33
+ renameSync(tmp, path)
34
+ }
35
+
36
+ function normalizePriority(value) {
37
+ if (!value || typeof value !== 'object') return null
38
+ const id = clean(value.id || value.competency)
39
+ const weight = Number(value.weight)
40
+ if (!id || !Number.isFinite(weight) || weight <= 0) return null
41
+ return { id, weight: Math.min(100, Math.round(weight)), reason: clean(value.reason) }
42
+ }
43
+
44
+ function normalizeProblem(value) {
45
+ if (!value || typeof value !== 'object') return null
46
+ const problemId = clean(value.problemId || value.id)
47
+ if (!problemId) return null
48
+ const status = clean(value.status || value.verdict || 'unknown').toUpperCase()
49
+ return {
50
+ problemId,
51
+ status,
52
+ readMinutes: Math.max(0, Number(value.readMinutes) || 0),
53
+ solveMinutes: Math.max(0, Number(value.solveMinutes ?? value.elapsedMinutes) || 0),
54
+ attempts: Math.max(0, Math.trunc(Number(value.attempts) || 0)),
55
+ competencies: asArray(value.competencies || value.skills).map(clean).filter(Boolean).slice(0, 12),
56
+ note: clean(value.note),
57
+ }
58
+ }
59
+
60
+ function normalizeEvent(value) {
61
+ if (!value || typeof value !== 'object') return null
62
+ const eventId = clean(value.eventId || value.id)
63
+ const contest = clean(value.contest || value.contestId)
64
+ const date = isoDay(value.date || value.startedAt?.slice?.(0, 10))
65
+ const durationMinutes = Math.max(0, Math.trunc(Number(value.durationMinutes) || 0))
66
+ const problems = asArray(value.problems).map(normalizeProblem).filter(Boolean)
67
+ if (!eventId || !contest || !date || !problems.length) return null
68
+ return {
69
+ eventId, contest, date, durationMinutes,
70
+ virtual: value.virtual !== false,
71
+ teamMode: clean(value.teamMode || 'solo'),
72
+ problems,
73
+ note: clean(value.note),
74
+ }
75
+ }
76
+
77
+ function normalizePostmortem(value) {
78
+ if (!value || typeof value !== 'object') return null
79
+ const eventId = clean(value.eventId)
80
+ if (!eventId) return null
81
+ return {
82
+ eventId,
83
+ date: isoDay(value.date) || new Date().toISOString().slice(0, 10),
84
+ competencies: asArray(value.competencies).map(clean).filter(Boolean).slice(0, 12),
85
+ missedReads: asArray(value.missedReads).map(clean).filter(Boolean).slice(0, 20),
86
+ decisionErrors: asArray(value.decisionErrors).map(clean).filter(Boolean).slice(0, 20),
87
+ teamIssues: asArray(value.teamIssues).map(clean).filter(Boolean).slice(0, 20),
88
+ rootCauses: asArray(value.rootCauses).map(clean).filter(Boolean).slice(0, 20),
89
+ nextActions: asArray(value.nextActions).map(clean).filter(Boolean).slice(0, 20),
90
+ note: clean(value.note),
91
+ }
92
+ }
93
+
94
+ export function createStrategyStore({ dataDir, competenciesPath, now = () => new Date() }) {
95
+ const targetPath = join(dataDir, 'TARGET.yaml')
96
+ const vpPath = join(dataDir, 'VP_EVENTS.yaml')
97
+ const loadTarget = () => ({ ...emptyTarget(), ...readYaml(targetPath, emptyTarget) })
98
+ const loadVp = () => ({ ...emptyVp(), ...readYaml(vpPath, emptyVp) })
99
+ const saveTarget = (doc) => writeYaml(targetPath, '# TARGET.yaml — 比赛目标与能力优先级\n', doc)
100
+ const saveVp = (doc) => writeYaml(vpPath, '# VP_EVENTS.yaml — VP 记录与赛后复盘\n', doc)
101
+ const loadCompetencies = () => readYaml(competenciesPath, () => ({ version: 1, competencies: [] })).competencies ?? []
102
+
103
+ function target(action = 'read', args = {}) {
104
+ const old = loadTarget()
105
+ if (action === 'read') return { ok: true, reject: '', ...old }
106
+ if (!['set', 'clear'].includes(action)) return { ok: false, reject: 'action 只能是 read/set/clear。', ...old }
107
+ if (args.expectedRevision !== old.revision) return { ok: false, reject: '目标版本已变化;先 read,再按当前 revision 更新。', ...old }
108
+ if (action === 'clear') {
109
+ const next = { ...emptyTarget(), revision: old.revision + 1, updated: now().toISOString() }
110
+ saveTarget(next)
111
+ return { ok: true, reject: '', ...next }
112
+ }
113
+ const date = clean(args.date)
114
+ if (!clean(args.contest) || !isoDay(date) || !clean(args.result)) return { ok: false, reject: 'set 必须提供 contest、真实 YYYY-MM-DD 的 date 和 result。', ...old }
115
+ const weeklyHours = Number(args.weeklyHours)
116
+ if (!Number.isFinite(weeklyHours) || weeklyHours <= 0 || weeklyHours > 168) return { ok: false, reject: 'weeklyHours 必须在 0 和 168 之间。', ...old }
117
+ const priorities = asArray(args.priorities).map(normalizePriority).filter(Boolean)
118
+ if (!priorities.length) return { ok: false, reject: '至少给一个能力优先级:id + weight。', ...old }
119
+ const known = new Set(loadCompetencies().map((item) => item.id))
120
+ const unknown = priorities.map((item) => item.id).filter((id) => !known.has(id))
121
+ if (unknown.length) return { ok: false, reject: `能力优先级没有范围卡片:${unknown.join('、')}。先补充 COMPETENCIES.yaml。`, ...old }
122
+ const teamMode = clean(args.teamMode || old.teamMode || 'solo')
123
+ if (!['solo', 'team'].includes(teamMode)) return { ok: false, reject: 'teamMode 只能是 solo 或 team。', ...old }
124
+ const next = {
125
+ ...old, contest: clean(args.contest), date, result: clean(args.result),
126
+ teamMode, weeklyHours,
127
+ constraints: args.constraints && typeof args.constraints === 'object' ? args.constraints : old.constraints,
128
+ priorities, revision: old.revision + 1, updated: now().toISOString(),
129
+ }
130
+ saveTarget(next)
131
+ return { ok: true, reject: '', ...next }
132
+ }
133
+
134
+ function vpImport(events = []) {
135
+ const old = loadVp()
136
+ const normalized = events.map(normalizeEvent).filter(Boolean)
137
+ const existing = new Map(old.events.map((event) => [event.eventId, event]))
138
+ for (const event of normalized) existing.set(event.eventId, event)
139
+ const next = { ...old, events: [...existing.values()].sort((a, b) => `${a.date}|${a.eventId}`.localeCompare(`${b.date}|${b.eventId}`)), revision: old.revision + 1, updated: now().toISOString() }
140
+ saveVp(next)
141
+ return { ok: true, reject: '', imported: normalized.length, total: next.events.length, revision: next.revision, events: next.events }
142
+ }
143
+
144
+ function postmortem(value) {
145
+ const eventId = clean(value?.eventId)
146
+ const vp = loadVp()
147
+ if (!vp.events.some((event) => event.eventId === eventId)) return { ok: false, reject: `找不到 VP「${eventId}」,先导入这场 VP。`, postmortems: vp.postmortems }
148
+ const record = normalizePostmortem(value)
149
+ if (!record || (!record.competencies.length && !record.rootCauses.length && !record.nextActions.length)) return { ok: false, reject: '复盘至少要写 competencies、rootCauses 或 nextActions 之一。', postmortems: vp.postmortems }
150
+ const list = vp.postmortems.filter((item) => item.eventId !== eventId)
151
+ list.push(record)
152
+ const next = { ...vp, postmortems: list, revision: vp.revision + 1, updated: now().toISOString() }
153
+ saveVp(next)
154
+ return { ok: true, reject: '', eventId, postmortems: next.postmortems, revision: next.revision }
155
+ }
156
+
157
+ function focus() {
158
+ const targetDoc = loadTarget()
159
+ const vp = loadVp()
160
+ const catalog = loadCompetencies()
161
+ if (!targetDoc.contest) return {
162
+ ok: false, reject: '还没有比赛目标,先用 coach_target set。', contest: '', deadline: '',
163
+ focus: [], postponed: [], evidenceEvents: vp.events.length, postmortems: vp.postmortems.length,
164
+ }
165
+ const postmortemHits = new Map()
166
+ for (const record of vp.postmortems) for (const id of [...record.competencies, ...record.rootCauses]) postmortemHits.set(id, (postmortemHits.get(id) ?? 0) + 1)
167
+ const focus = targetDoc.priorities.map((priority) => {
168
+ const meta = catalog.find((item) => item.id === priority.id) ?? { id: priority.id, title: priority.id, contestUse: '暂无比赛能力卡片,先补充定义。', nodes: [] }
169
+ const evidence = postmortemHits.get(priority.id) ?? 0
170
+ const urgency = Math.max(1, 30 - Math.floor((Date.parse(`${targetDoc.date}T12:00:00Z`) - Date.now()) / 86400000))
171
+ const score = Math.round(priority.weight * (1 + evidence) * urgency / 10) / 10
172
+ return { id: priority.id, title: meta.title ?? priority.id, score, evidenceCount: evidence, contestUse: meta.contestUse ?? '', nodes: meta.nodes ?? [], whyNow: evidence ? `最近复盘出现 ${evidence} 次相关问题;优先修复它。` : (priority.reason || '它是目标中的高权重能力,目前缺少 VP 证据。') }
173
+ }).sort((a, b) => b.score - a.score)
174
+ return { ok: true, reject: '', contest: targetDoc.contest, deadline: targetDoc.date, focus: focus.slice(0, 3), postponed: focus.slice(3).map((item) => item.id), evidenceEvents: vp.events.length, postmortems: vp.postmortems.length }
175
+ }
176
+
177
+ function scope(nodeOrId) {
178
+ const catalog = loadCompetencies()
179
+ const direct = catalog.find((item) => item.id === nodeOrId || item.nodes?.includes(nodeOrId))
180
+ if (direct) return { ok: true, reject: '', found: true, ...direct }
181
+ return { ok: false, reject: `「${nodeOrId}」还没有范围卡片,不能凭空编造适用范围;先补充 COMPETENCIES.yaml。`, found: false, id: nodeOrId, title: nodeOrId, contestUse: '', nodes: [], appliesTo: [], extensions: [], notFor: [], commonFailures: [], transferProblems: [], exitEvidence: [] }
182
+ }
183
+
184
+ function context() {
185
+ const t = loadTarget()
186
+ if (!t.contest) return '- 比赛目标:尚未建立;先用 coach_target set,不要把 rating 当成长期目标。'
187
+ const f = focus()
188
+ return [
189
+ `- 比赛目标:${t.contest},目标日期 ${t.date},结果目标 ${t.result},团队模式 ${t.teamMode}`,
190
+ `- 当前目标权重:${t.priorities.map((p) => `${p.id}(${p.weight})`).join('、')}`,
191
+ f.focus?.length ? `- 本轮优先能力:${f.focus.map((x) => `${x.title}(${x.whyNow})`).join(';')}` : '- 当前没有可计算的能力重点。',
192
+ f.postponed?.length ? `- 暂缓能力:${f.postponed.join('、')}` : '',
193
+ '- 每次布置前先说明它服务哪个比赛能力、适用范围、迁移题和退出条件。',
194
+ ].filter(Boolean).join('\n')
195
+ }
196
+
197
+ return { target, vpImport, postmortem, focus, scope, context, loadTarget, loadVp, loadCompetencies }
198
+ }
199
+
200
+ export const normalizeVpEvent = normalizeEvent
201
+ export const normalizeVpPostmortem = normalizePostmortem
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "acmer-coach",
3
3
  "description": "小鲸教练插件:把「位置 + 判断」从小鲸的转述里拿回来。读 MAP/DEPENDS/PROGRESS,给唯一动作、判因、检测。热插拔,删掉即还原。",
4
- "version": "1.2.1",
4
+ "version": "2.0.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/Miantiao1231/acmer-coach.git"
package/verify-shape.mjs CHANGED
@@ -167,6 +167,13 @@ const today = new Date().toLocaleDateString('sv-SE') // YYYY-MM-DD(本地)
167
167
  // 每个工具至少两条路:走得通的 + 被拒的(**被拒的那条最容易形状不齐**)
168
168
  const cases = [
169
169
  ['coach_ping', {}],
170
+ ['coach_target', { action: 'read' }],
171
+ ['coach_target', { action: 'set', expectedRevision: 0, contest: '测试区域赛', date: '2026-10-18', result: '金牌', teamMode: 'team', weeklyHours: 20, priorities: [{ id: 'contest-reading', weight: 80, reason: '测试目标' }] }],
172
+ ['coach_vp_import', { events: [{ eventId: 'shape-vp', contest: '测试 VP', date: today, durationMinutes: 300, problems: [{ problemId: 'A', status: 'AC', competencies: ['contest-reading'] }] }] }],
173
+ ['coach_postmortem', { eventId: 'shape-vp', competencies: ['contest-reading'], rootCauses: ['测试复盘'], nextActions: ['测试动作'] }],
174
+ ['coach_focus', {}],
175
+ ['coach_scope', { id: 'contest-reading' }],
176
+ ['coach_scope', { id: '不存在的能力' }],
170
177
  // 无课程的读取 / 无课程的推进拒绝,不建立课程以免改变后续旧工具夹具。
171
178
  ['coach_curriculum', { action: 'read' }],
172
179
  ['coach_curriculum', { action: 'assess' }],