acmer-coach 2.0.0 → 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/README.md CHANGED
@@ -75,7 +75,7 @@ VP 记录格式是 JSON 数组,每场至少包含 `eventId`、`contest`、`dat
75
75
  ### 长期学习路线
76
76
 
77
77
  `coach_curriculum assess` 从已有检测、代码判因、做题记录和日程实际完成中列出
78
- 可引用的证据;不会把 rating、学习状态或取消的卷子当成短板。每条记录带内容引用
78
+ 可引用的证据;VP 题目与复盘里的 `competencies` 也会生成稳定证据 id,不会把 rating、学习状态或取消的卷子当成短板。每条记录带内容引用
79
79
  `id` 和事件身份 `eventId`。训练阶段的每个节点必须有真实记录:`finding=gap`
80
80
  引用失败/超时/依赖提示等短板,`finding=extension` 引用已独立做出或过卷的能力起点。
81
81
 
@@ -86,8 +86,7 @@ VP 记录格式是 JSON 数组,每场至少包含 `eventId`、`contest`、`dat
86
86
  learned/verified 记录,或路线中在此前安排的必验训练,不能从游标位置推定已经学会。
87
87
 
88
88
  `CURRICULUM.yaml` 保存路线与修订历史:`read` 查看,`create` 建立,`revise` 调整,
89
- `advance` 推进。进入阶段时保存证据事件快照;诊断阶段每个节点需有新的真实观察,
90
- 训练阶段所有节点必须 verified,且各有新的独立做出或过卷记录。改旧备注、成绩或
89
+ `advance` 推进。阶段可以只绑定比赛能力(读题、决策、团队沟通),这类阶段用 VP/复盘证据验收,不必伪装成地图节点。进入阶段时保存证据事件快照;诊断阶段每个节点需有新的真实观察,训练阶段所有节点必须 verified,能力目标必须有阶段后的新 baseline 证据。改旧备注、成绩或
91
90
  把同次做题从课表迁入进度,不算新训练。当前诊断也不能通过改种类或删节点逃过验收。
92
91
  写操作带 `expectedRevision` 和理由,避免旧上下文覆盖新计划。
93
92
  路线按剩余 `estimatedHours`、每周预算和目标日期提示容量不足或估算缺失;这只是
@@ -30,7 +30,7 @@
30
30
  必须填写 `basis`、`goalContribution`、`priorityReason`、`estimatedHours`、
31
31
  `evidenceRefs`、`finding`、`kind`、`uncertainty`、`diagnosticMethod`。
32
32
  说清观察到了什么、它怎样服务目标、为何排在这里、预计投入多少,不套统一算法清单。
33
- * `kind=training` 的每个节点都要对应真实证据:补短板用 `finding=gap`,引用 gap;
33
+ * `kind=training` 的每个节点都要对应真实证据:补短板用 `finding=gap`,引用 gap;读题、决策、团队沟通这类没有单一地图节点的阶段改用 `competencies`,引用 VP/复盘产生的能力证据;
34
34
  在已证实能力上进阶用 `finding=extension`,引用 baseline。记录指向什么由你解读,
35
35
  但**不能写一段理由替代不存在的记录**,也不能把别的节点的失败贴来凑依据。
36
36
  * 缺依据先安排 `kind=diagnostic/finding=unknown`,写尚待确认的问题和具体取证方法。
@@ -336,6 +336,26 @@ test('broken course is surfaced and never treated as an empty plan', () => {
336
336
  assert.equal(readFileSync(f.store.path, 'utf8'), 'revision: broken\n')
337
337
  })
338
338
 
339
+ test('competency-only phases can use fresh VP evidence as their exit gate', () => {
340
+ const f = fixture()
341
+ const evidence = [{ id: 'vp:old:contest-reading', eventId: 'old', source: 'vp', node: '', competency: 'contest-reading', date: '2026-10-08', signal: 'gap', summary: 'VP 读题漏读', fingerprint: 'vp:old:contest-reading' }]
342
+ f.options.loadStrategyEvidence = () => evidence
343
+ const store = createCurriculumStore(f.options)
344
+ const phase = {
345
+ id: 'reading', title: '比赛读题与筛选', outcome: '在前30分钟完成扫描并记录取舍', competencies: ['contest-reading'], nodes: [],
346
+ exitCriteria: '两场新 VP 都有扫描顺序、取舍理由和漏读检查', requiredVerified: [], kind: 'training', finding: 'gap',
347
+ basis: 'VP 已记录读题缺口,需要在真实比赛节奏中修复', goalContribution: '提高区域赛前半小时的可做题识别和取舍质量',
348
+ priorityReason: '它直接决定队伍是否把时间用在能拿分的题上', estimatedHours: 6, uncertainty: '', diagnosticMethod: '',
349
+ evidenceRefs: ['vp:old:contest-reading'],
350
+ }
351
+ assert.equal(store.execute({ action: 'create', expectedRevision: 0, goal: '区域赛稳定表现', baseline: '已有一场 VP 的读题缺口记录', weeklyHours: 30, phases: [phase], reason: '根据 VP 的读题记录建立能力课程' }).ok, true)
352
+ assert.deepEqual(store.snapshot().phaseCompetencies, ['contest-reading'])
353
+ assert.deepEqual(store.snapshot().missingFreshEvidence, ['contest-reading'])
354
+ evidence.push({ id: 'vp:new:contest-reading', eventId: 'new', source: 'vp', node: '', competency: 'contest-reading', date: '2026-10-09', signal: 'baseline', summary: '新 VP 读题记录', fingerprint: 'vp:new:contest-reading' })
355
+ assert.deepEqual(store.snapshot().missingFreshEvidence, [])
356
+ assert.equal(store.execute({ action: 'advance', expectedRevision: 1, reason: '新 VP 已记录扫描顺序和取舍理由,满足本阶段能力验收' }).status, 'complete')
357
+ })
358
+
339
359
  test('course tool output matches its declared schema on success, rejection and absence', () => {
340
360
  const f = fixture(); const tool = curriculumTool(f.store)
341
361
  for (const result of [f.store.execute({ action: 'read' }), f.create(), f.store.execute({ action: 'advance', expectedRevision: 1, reason: '检查未验收阶段是否能够被强行推进' })]) validateShape(tool.output.schema, result)
@@ -1,11 +1,12 @@
1
1
  import test from 'node:test'
2
2
  import assert from 'node:assert/strict'
3
- import { mkdtempSync, rmSync, existsSync } from 'node:fs'
3
+ import { mkdtempSync, rmSync, existsSync, writeFileSync } from 'node:fs'
4
4
  import { join } from 'node:path'
5
+ import { tmpdir } from 'node:os'
5
6
  import { createStrategyStore } from '../lib/strategy.js'
6
7
 
7
8
  function fixture() {
8
- const dir = mkdtempSync(join(process.cwd(), '.strategy-test-'))
9
+ const dir = mkdtempSync(join(tmpdir(), 'acmer-coach-strategy-'))
9
10
  const store = createStrategyStore({
10
11
  dataDir: dir,
11
12
  competenciesPath: join(process.cwd(), 'assets', 'knowledge', 'COMPETENCIES.yaml'),
@@ -73,3 +74,85 @@ test('first-use focus has a complete refusal shape and targets reject unknown ca
73
74
  assert.equal(rejected.ok, false)
74
75
  } finally { rmSync(dir, { recursive: true, force: true }) }
75
76
  })
77
+
78
+ test('target route rejects an unrelated progress node but allows a course-bound route', () => {
79
+ const { dir, store } = fixture()
80
+ try {
81
+ store.target('set', {
82
+ expectedRevision: 0, contest: '西安区域赛', date: '2026-10-18', result: '区域赛金牌',
83
+ weeklyHours: 35, priorities: [{ id: 'dp-modeling', weight: 100 }],
84
+ })
85
+ const unrelated = store.route('树状数组', { purpose: 'progress', courseBound: false })
86
+ assert.equal(unrelated.ok, false)
87
+ assert.match(unrelated.reject, /dp-modeling|当前重点/)
88
+ const courseBound = store.route('树状数组', { purpose: 'progress', courseBound: true })
89
+ assert.equal(courseBound.ok, true)
90
+ assert.equal(courseBound.courseBound, true)
91
+ } finally { rmSync(dir, { recursive: true, force: true }) }
92
+ })
93
+
94
+ test('VP problem competencies count as evidence before a postmortem exists', () => {
95
+ const { dir, store } = fixture()
96
+ try {
97
+ store.target('set', {
98
+ expectedRevision: 0, contest: '西安区域赛', date: '2026-10-18', result: '区域赛金牌',
99
+ weeklyHours: 35, priorities: [
100
+ { id: 'dp-modeling', weight: 80 },
101
+ { id: 'contest-reading', weight: 70 },
102
+ ],
103
+ })
104
+ store.vpImport([{ eventId: 'vp-2', contest: '训练 VP', date: '2026-10-10', durationMinutes: 300,
105
+ problems: [{ problemId: 'B', status: 'WA', competencies: ['dp-modeling'] }] }])
106
+ const focus = store.focus()
107
+ assert.equal(focus.focus[0].id, 'dp-modeling')
108
+ assert.equal(focus.focus[0].evidenceCount, 1)
109
+ } finally { rmSync(dir, { recursive: true, force: true }) }
110
+ })
111
+
112
+ test('strategy exposes stable competency evidence for curriculum gates', () => {
113
+ const { dir, store } = fixture()
114
+ try {
115
+ store.vpImport([{ eventId: 'vp-evidence', contest: '训练 VP', date: '2026-10-10', durationMinutes: 300,
116
+ problems: [{ problemId: 'C', status: 'WA', competencies: ['contest-decision'] }] }])
117
+ const result = store.evidence()
118
+ assert.equal(result.ok, true)
119
+ assert.equal(result.evidence[0].id, 'vp:vp-evidence:C:contest-decision')
120
+ assert.equal(result.evidence[0].signal, 'gap')
121
+ assert.equal(result.evidence[0].competency, 'contest-decision')
122
+ } finally { rmSync(dir, { recursive: true, force: true }) }
123
+ })
124
+
125
+ test('invalid VP input is rejected without bumping revision', () => {
126
+ const { dir, store } = fixture()
127
+ try {
128
+ const result = store.vpImport([{ eventId: '', contest: '坏记录', date: '2026-10-10', problems: [] }])
129
+ assert.equal(result.ok, false)
130
+ assert.equal(result.revision, 0)
131
+ assert.equal(result.imported, 0)
132
+ assert.equal(result.invalid.length, 1)
133
+ assert.equal(existsSync(join(dir, 'VP_EVENTS.yaml')), false)
134
+ } finally { rmSync(dir, { recursive: true, force: true }) }
135
+ })
136
+
137
+ test('invalid target priorities are rejected instead of silently dropped', () => {
138
+ const { dir, store } = fixture()
139
+ try {
140
+ const result = store.target('set', {
141
+ expectedRevision: 0, contest: '西安区域赛', date: '2026-10-18', result: '区域赛金牌',
142
+ weeklyHours: 35, priorities: [{ id: 'dp-modeling', weight: 'bad' }],
143
+ })
144
+ assert.equal(result.ok, false)
145
+ assert.match(result.reject, /priorit|weight|能力/)
146
+ assert.equal(result.revision, 0)
147
+ } finally { rmSync(dir, { recursive: true, force: true }) }
148
+ })
149
+
150
+ test('corrupt stored strategy data returns a structured refusal', () => {
151
+ const { dir, store } = fixture()
152
+ try {
153
+ writeFileSync(join(dir, 'TARGET.yaml'), 'version: 1\nrevision: 4\npriorities: bad\n', 'utf8')
154
+ const result = store.target('read')
155
+ assert.equal(result.ok, false)
156
+ assert.match(result.reject, /TARGET.yaml/)
157
+ } finally { rmSync(dir, { recursive: true, force: true }) }
158
+ })
@@ -59,8 +59,11 @@ assess 最多返回最后 200 条,并给出 `evidenceCount/evidenceTruncated`
59
59
 
60
60
  ## 阶段如何编排
61
61
 
62
- 基础字段 `id/title/outcome/nodes/exitCriteria` 分别标识阶段、能力成果、地图节点和
63
- 验收方法;一个阶段 1–50 个不重复的真实节点,整条路线 1–30 个阶段。还必须提供:
62
+ 基础字段 `id/title/outcome/nodes/competencies/exitCriteria` 分别标识阶段、能力成果、地图节点、比赛能力和验收方法;一个阶段至少绑定一个真实地图节点或比赛能力,地图节点最多 50 个,整条路线 1–30 个阶段。读题、决策、团队沟通这类没有单一算法节点的阶段直接填 `competencies`。
63
+
64
+ VP 题目里的 `competencies` 和复盘里的 `competencies` 会生成稳定证据 id(如 `vp:event:problem:competency`),进入课程时被快照;能力阶段只能用进入阶段后的新 baseline 记录通过 `advance`。
65
+
66
+ 还必须提供:
64
67
 
65
68
  | 字段 | 要回答的问题 |
66
69
  |---|---|
@@ -76,7 +79,7 @@ assess 最多返回最后 200 条,并给出 `evidenceCount/evidenceTruncated`
76
79
 
77
80
  三种可编排阶段:
78
81
 
79
- * **training**:每个节点都必须有对应证据。`finding=gap` 必须引用该节点的 gap;
82
+ * **training**:每个节点或比赛能力都必须有对应证据。`finding=gap` 必须引用该节点/能力的 gap;
80
83
  `finding=extension` 必须引用该节点的 baseline。无关节点的失败不能用来凑依据。
81
84
  所有节点都必须列入 `requiredVerified`,不能用空数组逃过检测。
82
85
  * **diagnostic**:`finding=unknown`,明确待确认问题、取证方法与能力目标的关系。
@@ -111,7 +114,7 @@ assess 最多返回最后 200 条,并给出 `evidenceCount/evidenceTruncated`
111
114
  进入阶段时保存 `enteredAt/entryEvidenceRefs/entryEvidenceEvents`。advance 必须同时满足:
112
115
 
113
116
  * diagnostic:每个节点都有进入时未出现、日期不早于进入日的新有效观察。
114
- * training:全部节点 verified,且每个节点有本阶段新的独立做出或检测 passed 事件。
117
+ * training:地图节点全部 verified,且每个节点有本阶段新的独立做出或检测 passed 事件;能力目标全部有本阶段新的 baseline 证据。
115
118
  * 当前阶段没有待完成动作,也没有检测卷等待判定。
116
119
 
117
120
  旧记录改内容或换来源不能替代新事件。诊断阶段不能通过 revise 改成训练或删除待诊断
package/index.js CHANGED
@@ -23,7 +23,7 @@
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'
@@ -35,7 +35,7 @@ import { createStrategyStore } from './lib/strategy.js'
35
35
  const name = 'acmer-coach'
36
36
  const inject = ['tools']
37
37
 
38
- const VERSION = '2.0.0'
38
+ const VERSION = '2.1.0'
39
39
 
40
40
  // 块结构(方针 §4.1.1):40 自己做 + 10 解决遗留 + 10 重写 = 60 分钟。
41
41
  // 这是**一个题目循环**的固定形状。时间不够该换更小的题,不是把块压扁。
@@ -71,11 +71,14 @@ const TEACH_FLOOR_MIN = 10
71
71
  const DATA_DIR_EXPLICIT = Boolean(process.env.COACH_DATA_DIR)
72
72
  const DATA_DIR = process.env.COACH_DATA_DIR || join(homedir(), '.dsh', 'knowledge')
73
73
  const MAP_PATH = () => join(DATA_DIR, 'MAP.yaml')
74
- const curriculum = createCurriculumStore({ dataDir: DATA_DIR, loadMap, loadProgress, loadSchedule })
75
74
  const strategy = createStrategyStore({
76
75
  dataDir: DATA_DIR,
77
76
  competenciesPath: join(import.meta.dirname, 'assets', 'knowledge', 'COMPETENCIES.yaml'),
78
77
  })
78
+ const curriculum = createCurriculumStore({
79
+ dataDir: DATA_DIR, loadMap, loadProgress, loadSchedule,
80
+ loadStrategyEvidence: () => strategy.evidence().evidence,
81
+ })
79
82
 
80
83
  // 探针要的文件,各自代表一条后面的依赖:
81
84
  // MAP.yaml 地图(coach_next 读它算候选)
@@ -256,6 +259,16 @@ const PROGRESS_HEADER = `
256
259
  // 原子写:先写 .tmp 再 rename。半路崩了也不会把真相源撕成半个文件。
257
260
  // ⚠️ 千万不要在解析失败时静默返回空进度再写回 —— 那是**拿空数据覆盖真相源**。
258
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
+
259
272
  function saveProgress(p) {
260
273
  p.updated = localStamp(new Date()).slice(0, 10)
261
274
  // 显式列出要落盘的字段,而不是 `stringify(p)` 一把梭 ——
@@ -276,7 +289,7 @@ function saveProgress(p) {
276
289
  }
277
290
  const tmp = `${PROGRESS_PATH()}.tmp`
278
291
  writeFileSync(tmp, PROGRESS_HEADER + stringify(out, { lineWidth: 0 }), 'utf8')
279
- renameSync(tmp, PROGRESS_PATH())
292
+ replaceFile(tmp, PROGRESS_PATH())
280
293
  return p
281
294
  }
282
295
 
@@ -370,7 +383,7 @@ function saveSchedule(s) {
370
383
  // days 的 key 本身就是日期,也裸着 —— PyYAML 会读成 Date,按字符串索引就 KeyError
371
384
  body = body.replace(/^(\s*)(\d{4}-\d{2}-\d{2}):(\s*)$/gm, '$1"$2":')
372
385
  writeFileSync(tmp, SCHEDULE_HEADER + body, 'utf8')
373
- renameSync(tmp, SCHEDULE_PATH())
386
+ replaceFile(tmp, SCHEDULE_PATH())
374
387
  return s
375
388
  }
376
389
 
@@ -1865,11 +1878,11 @@ function apply(ctx) {
1865
1878
  },
1866
1879
  output: { schema: { type: 'object', additionalProperties: false, properties: {
1867
1880
  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 },
1881
+ total: { type: 'integer', required: true }, revision: { type: 'integer', required: true }, invalid: { type: 'array', required: true, items: { type: 'string' } }, text: { type: 'string', required: true },
1869
1882
  } }, render: (_a, v) => strategyText(v) },
1870
1883
  execute: (args) => {
1871
1884
  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,
1885
+ return { ok: v.ok, reject: v.reject, imported: v.imported, total: v.total, revision: v.revision, invalid: v.invalid ?? [],
1873
1886
  text: v.ok ? `✓ VP 记录已保存:本次 ${v.imported} 场,累计 ${v.total} 场,revision ${v.revision}` : '' }
1874
1887
  },
1875
1888
  }))
@@ -1901,11 +1914,32 @@ function apply(ctx) {
1901
1914
  ok: { type: 'boolean', required: true }, reject: { type: 'string', required: true }, contest: { type: 'string', required: true },
1902
1915
  deadline: { type: 'string', required: true }, focus: { type: 'array', required: true }, postponed: { type: 'array', required: true },
1903
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
+ } },
1904
1924
  } }, render: (_a, v) => strategyText(v) },
1905
1925
  execute: () => {
1906
1926
  const v = strategy.focus()
1907
- return { ...v, text: v.ok ? [`【当前训练重点】${v.contest} · 截止 ${v.deadline}`,
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}`,
1908
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('、')}` : '',
1909
1943
  v.postponed.length ? `暂缓:${v.postponed.join('、')}` : '没有暂缓项。',
1910
1944
  ].join('\n') : '' }
1911
1945
  },
@@ -2681,7 +2715,9 @@ function apply(ctx) {
2681
2715
  '必须存在于地图、必须是当前游标的下一层、前置必须都满足。校验不过会被拒绝并说明原因。' +
2682
2716
  '它一次只收一个节点,所以**给不出候选列表** —— 这正是设计意图。' +
2683
2717
  '有长期课程时先读 coach_curriculum,带 curriculumRevision;主线必须属于当前阶段,' +
2684
- '补漏或复习用 purpose 与 routeReason 说明关系和回归条件。课程节点可以跨图分支,但前置检查仍生效。',
2718
+ '补漏或复习用 purpose 与 routeReason 说明关系和回归条件。课程节点可以跨图分支,但前置检查仍生效。' +
2719
+ '有比赛目标时,progress 动作还必须落在当前目标能力;跨重点训练只能走课程绑定,' +
2720
+ '补漏/复习必须说明 routeReason。',
2685
2721
  parameters: {
2686
2722
  cursor: {
2687
2723
  type: 'string',
@@ -2740,6 +2776,8 @@ function apply(ctx) {
2740
2776
  pendingRecorded: { type: 'boolean', required: true },
2741
2777
  // 这个节点上原来就押着一条(被这次覆盖)。
2742
2778
  pendingReplaced: { type: 'boolean', required: true },
2779
+ targetAligned: { type: 'boolean', required: true },
2780
+ competencies: { type: 'array', required: true, items: { type: 'string' } },
2743
2781
  blocks: {
2744
2782
  type: 'array',
2745
2783
  required: true,
@@ -2778,6 +2816,7 @@ function apply(ctx) {
2778
2816
  v.boxNote ? `⚠️ ${v.boxNote}` : '',
2779
2817
  `交付物 ${v.deliverable}`,
2780
2818
  v.why ? `为什么 ${v.why}` : '',
2819
+ v.competencies.length ? `目标对齐 ✓ ${v.competencies.join('、')}` : '',
2781
2820
  ``,
2782
2821
  table,
2783
2822
  v.teachMinutes
@@ -2818,7 +2857,7 @@ function apply(ctx) {
2818
2857
  // 需求 #6:拒绝时也把这两个字段摆出来 —— 白名单式的 schema
2819
2858
  // (additionalProperties: false + required:true)要求每个出口同形,
2820
2859
  // 缺一个 verify-shape 那一关就会红。
2821
- pendingRecorded: false, pendingReplaced: false,
2860
+ pendingRecorded: false, pendingReplaced: false, targetAligned: false, competencies: [],
2822
2861
  })
2823
2862
 
2824
2863
  // ① 游标认不认得。「没设」和「打错」分开提示,别让模型去猜。
@@ -2847,6 +2886,11 @@ function apply(ctx) {
2847
2886
  why = `课程「${route.binding.phaseTitle}」· ${route.binding.purpose}:${why}` +
2848
2887
  (route.binding.reason ? `;补充依据:${route.binding.reason}` : '')
2849
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
+ }
2850
2894
 
2851
2895
  // ③ 未建课程时沿用直接后继;有课程时允许当前阶段跨分支,仍检查前置。
2852
2896
  //
@@ -2976,6 +3020,7 @@ function apply(ctx) {
2976
3020
  totalMinutes: net + teach + TAIL_MIN, boxNote,
2977
3021
  deliverable, why, mapStamp: stamp, selfTarget: node === cursor,
2978
3022
  pendingRecorded, pendingReplaced: replacedPending,
3023
+ targetAligned: targetRoute.targetAligned, competencies: targetRoute.competencyIds,
2979
3024
  blocks: [
2980
3025
  // 讲解段排在最前面,`kind: teach` —— 卷面上看得见,
2981
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 ? '本周主线到期,先复盘并修订。' : '',
package/lib/strategy.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // Goal-driven strategy layer for acmer-coach.
2
2
  // It stores contest targets, VP evidence and postmortems separately from
3
3
  // PROGRESS.yaml so the existing knowledge workflow keeps its meaning.
4
- import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
4
+ import { copyFileSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
5
5
  import { join } from 'node:path'
6
6
  import { parse, stringify } from 'yaml'
7
7
 
@@ -29,8 +29,22 @@ function readYaml(path, fallback) {
29
29
 
30
30
  function writeYaml(path, header, value) {
31
31
  const tmp = `${path}.${process.pid}.${Date.now()}.tmp`
32
- writeFileSync(tmp, header + stringify(value, { lineWidth: 0 }), 'utf8')
33
- renameSync(tmp, path)
32
+ let moved = false
33
+ try {
34
+ writeFileSync(tmp, header + stringify(value, { lineWidth: 0 }), 'utf8')
35
+ try { renameSync(tmp, path) } catch (err) {
36
+ if (!['EPERM', 'EEXIST'].includes(err.code)) throw err
37
+ // Windows sandbox may refuse rename over an existing file; keep the complete tmp file and replace by copy.
38
+ try { unlinkSync(path) } catch (removeErr) { if (removeErr.code !== 'ENOENT') throw err }
39
+ copyFileSync(tmp, path)
40
+ unlinkSync(tmp)
41
+ }
42
+ moved = true
43
+ } finally {
44
+ if (!moved) {
45
+ try { unlinkSync(tmp) } catch { /* best effort cleanup */ }
46
+ }
47
+ }
34
48
  }
35
49
 
36
50
  function normalizePriority(value) {
@@ -94,14 +108,46 @@ function normalizePostmortem(value) {
94
108
  export function createStrategyStore({ dataDir, competenciesPath, now = () => new Date() }) {
95
109
  const targetPath = join(dataDir, 'TARGET.yaml')
96
110
  const vpPath = join(dataDir, 'VP_EVENTS.yaml')
97
- const loadTarget = () => ({ ...emptyTarget(), ...readYaml(targetPath, emptyTarget) })
98
- const loadVp = () => ({ ...emptyVp(), ...readYaml(vpPath, emptyVp) })
111
+ const validateTarget = (doc) => {
112
+ const errors = []
113
+ if (doc.version !== 1) errors.push('version 必须是 1')
114
+ if (!Number.isInteger(doc.revision) || doc.revision < 0) errors.push('revision 必须是非负整数')
115
+ if (!Array.isArray(doc.priorities)) errors.push('priorities 必须是数组')
116
+ else if (doc.priorities.some((item) => !normalizePriority(item))) errors.push('priorities 含有无效的 id/weight')
117
+ if (!doc.constraints || typeof doc.constraints !== 'object' || Array.isArray(doc.constraints)) errors.push('constraints 必须是对象')
118
+ return errors
119
+ }
120
+ const validateVp = (doc) => {
121
+ const errors = []
122
+ if (doc.version !== 1) errors.push('version 必须是 1')
123
+ if (!Number.isInteger(doc.revision) || doc.revision < 0) errors.push('revision 必须是非负整数')
124
+ if (!Array.isArray(doc.events)) errors.push('events 必须是数组')
125
+ else if (doc.events.some((item) => !normalizeEvent(item))) errors.push('events 含有无效记录')
126
+ if (!Array.isArray(doc.postmortems)) errors.push('postmortems 必须是数组')
127
+ else if (doc.postmortems.some((item) => !normalizePostmortem(item))) errors.push('postmortems 含有无效记录')
128
+ return errors
129
+ }
130
+ const loadTarget = () => {
131
+ const doc = { ...emptyTarget(), ...readYaml(targetPath, emptyTarget) }
132
+ const errors = validateTarget(doc)
133
+ if (errors.length) throw new Error(`TARGET.yaml 数据无效:${errors.join(';')}`)
134
+ return doc
135
+ }
136
+ const loadVp = () => {
137
+ const doc = { ...emptyVp(), ...readYaml(vpPath, emptyVp) }
138
+ const errors = validateVp(doc)
139
+ if (errors.length) throw new Error(`VP_EVENTS.yaml 数据无效:${errors.join(';')}`)
140
+ return doc
141
+ }
99
142
  const saveTarget = (doc) => writeYaml(targetPath, '# TARGET.yaml — 比赛目标与能力优先级\n', doc)
100
143
  const saveVp = (doc) => writeYaml(vpPath, '# VP_EVENTS.yaml — VP 记录与赛后复盘\n', doc)
101
144
  const loadCompetencies = () => readYaml(competenciesPath, () => ({ version: 1, competencies: [] })).competencies ?? []
102
145
 
103
146
  function target(action = 'read', args = {}) {
104
- const old = loadTarget()
147
+ let old
148
+ try { old = loadTarget() } catch (err) {
149
+ return { ok: false, reject: err.message, ...emptyTarget() }
150
+ }
105
151
  if (action === 'read') return { ok: true, reject: '', ...old }
106
152
  if (!['set', 'clear'].includes(action)) return { ok: false, reject: 'action 只能是 read/set/clear。', ...old }
107
153
  if (args.expectedRevision !== old.revision) return { ok: false, reject: '目标版本已变化;先 read,再按当前 revision 更新。', ...old }
@@ -114,7 +160,9 @@ export function createStrategyStore({ dataDir, competenciesPath, now = () => new
114
160
  if (!clean(args.contest) || !isoDay(date) || !clean(args.result)) return { ok: false, reject: 'set 必须提供 contest、真实 YYYY-MM-DD 的 date 和 result。', ...old }
115
161
  const weeklyHours = Number(args.weeklyHours)
116
162
  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)
163
+ const rawPriorities = asArray(args.priorities)
164
+ const priorities = rawPriorities.map(normalizePriority).filter(Boolean)
165
+ if (rawPriorities.length !== priorities.length) return { ok: false, reject: 'priorities 中有无效项;每项都要提供正数 weight 和非空 id。', ...old }
118
166
  if (!priorities.length) return { ok: false, reject: '至少给一个能力优先级:id + weight。', ...old }
119
167
  const known = new Set(loadCompetencies().map((item) => item.id))
120
168
  const unknown = priorities.map((item) => item.id).filter((id) => !known.has(id))
@@ -132,13 +180,19 @@ export function createStrategyStore({ dataDir, competenciesPath, now = () => new
132
180
  }
133
181
 
134
182
  function vpImport(events = []) {
135
- const old = loadVp()
136
- const normalized = events.map(normalizeEvent).filter(Boolean)
183
+ let old
184
+ try { old = loadVp() } catch (err) {
185
+ return { ok: false, reject: err.message, imported: 0, total: 0, revision: 0, events: [], invalid: [] }
186
+ }
187
+ if (!Array.isArray(events) || !events.length) return { ok: false, reject: '至少导入一场含题目记录的 VP;空数组不会修改现有数据。', imported: 0, total: old.events.length, revision: old.revision, events: old.events, invalid: ['events 为空'] }
188
+ const normalized = events.map(normalizeEvent)
189
+ const invalid = normalized.map((event, index) => event ? '' : `第 ${index + 1} 条 VP 记录缺 eventId/contest/date/problems`).filter(Boolean)
190
+ if (invalid.length) return { ok: false, reject: 'VP 导入被拒绝:' + invalid.join(';'), imported: 0, total: old.events.length, revision: old.revision, events: old.events, invalid }
137
191
  const existing = new Map(old.events.map((event) => [event.eventId, event]))
138
192
  for (const event of normalized) existing.set(event.eventId, event)
139
193
  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
194
  saveVp(next)
141
- return { ok: true, reject: '', imported: normalized.length, total: next.events.length, revision: next.revision, events: next.events }
195
+ return { ok: true, reject: '', imported: normalized.length, total: next.events.length, revision: next.revision, events: next.events, invalid: [] }
142
196
  }
143
197
 
144
198
  function postmortem(value) {
@@ -155,8 +209,16 @@ export function createStrategyStore({ dataDir, competenciesPath, now = () => new
155
209
  }
156
210
 
157
211
  function focus() {
158
- const targetDoc = loadTarget()
159
- const vp = loadVp()
212
+ let targetDoc, vp
213
+ try {
214
+ targetDoc = loadTarget()
215
+ vp = loadVp()
216
+ } catch (err) {
217
+ return {
218
+ ok: false, reject: err.message, contest: '', deadline: '', focus: [], postponed: [],
219
+ evidenceEvents: 0, postmortems: 0,
220
+ }
221
+ }
160
222
  const catalog = loadCompetencies()
161
223
  if (!targetDoc.contest) return {
162
224
  ok: false, reject: '还没有比赛目标,先用 coach_target set。', contest: '', deadline: '',
@@ -164,9 +226,15 @@ export function createStrategyStore({ dataDir, competenciesPath, now = () => new
164
226
  }
165
227
  const postmortemHits = new Map()
166
228
  for (const record of vp.postmortems) for (const id of [...record.competencies, ...record.rootCauses]) postmortemHits.set(id, (postmortemHits.get(id) ?? 0) + 1)
229
+ const problemHits = new Map()
230
+ for (const event of vp.events) {
231
+ for (const id of new Set(event.problems.flatMap((problem) => problem.competencies))) {
232
+ problemHits.set(id, (problemHits.get(id) ?? 0) + 1)
233
+ }
234
+ }
167
235
  const focus = targetDoc.priorities.map((priority) => {
168
236
  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
237
+ const evidence = (postmortemHits.get(priority.id) ?? 0) + (problemHits.get(priority.id) ?? 0)
170
238
  const urgency = Math.max(1, 30 - Math.floor((Date.parse(`${targetDoc.date}T12:00:00Z`) - Date.now()) / 86400000))
171
239
  const score = Math.round(priority.weight * (1 + evidence) * urgency / 10) / 10
172
240
  return { id: priority.id, title: meta.title ?? priority.id, score, evidenceCount: evidence, contestUse: meta.contestUse ?? '', nodes: meta.nodes ?? [], whyNow: evidence ? `最近复盘出现 ${evidence} 次相关问题;优先修复它。` : (priority.reason || '它是目标中的高权重能力,目前缺少 VP 证据。') }
@@ -181,6 +249,47 @@ export function createStrategyStore({ dataDir, competenciesPath, now = () => new
181
249
  return { ok: false, reject: `「${nodeOrId}」还没有范围卡片,不能凭空编造适用范围;先补充 COMPETENCIES.yaml。`, found: false, id: nodeOrId, title: nodeOrId, contestUse: '', nodes: [], appliesTo: [], extensions: [], notFor: [], commonFailures: [], transferProblems: [], exitEvidence: [] }
182
250
  }
183
251
 
252
+ function route(nodeOrId, { purpose = 'progress', courseBound = false } = {}) {
253
+ const node = clean(nodeOrId)
254
+ let targetDoc
255
+ try { targetDoc = loadTarget() } catch (err) { return { ok: false, reject: err.message, node, purpose, courseBound: Boolean(courseBound), targetAligned: false, competencyIds: [] } }
256
+ const base = { node, purpose, courseBound: Boolean(courseBound), targetAligned: true, competencyIds: [] }
257
+ if (!targetDoc.contest || purpose !== 'progress' || courseBound) return { ok: true, reject: '', ...base }
258
+ const catalog = loadCompetencies()
259
+ const priorityIds = new Set(targetDoc.priorities.map((item) => item.id))
260
+ const current = focus()
261
+ const currentIds = new Set((current.focus ?? []).map((item) => item.id))
262
+ const targetCards = catalog.filter((item) => priorityIds.has(item.id) && item.nodes?.includes(node))
263
+ const currentCards = targetCards.filter((item) => currentIds.has(item.id))
264
+ const mappedTarget = catalog.filter((item) => priorityIds.has(item.id) && item.nodes?.length)
265
+ if (mappedTarget.length && !currentCards.length) {
266
+ return { ok: false, reject: `「${node}」不服务当前比赛重点;当前重点是 ${current.focus.map((item) => item.id).join('、')}。如果这是课程主线,请带 curriculumRevision;如果是补漏/复习,请声明 purpose 和 routeReason。`, ...base, targetAligned: false, competencyIds: targetCards.map((item) => item.id) }
267
+ }
268
+ return { ok: true, reject: '', ...base, targetAligned: currentCards.length > 0, competencyIds: currentCards.map((item) => item.id) }
269
+ }
270
+
271
+ function evidence() {
272
+ try {
273
+ const vp = loadVp()
274
+ const result = []
275
+ const baselineStatuses = new Set(['AC', 'OK', 'ACCEPTED', 'SOLVED'])
276
+ for (const event of vp.events) for (const problem of event.problems) {
277
+ const signal = baselineStatuses.has(problem.status) ? 'baseline' : 'gap'
278
+ for (const competency of new Set(problem.competencies)) {
279
+ const id = `vp:${event.eventId}:${problem.problemId}:${competency}`
280
+ result.push({ id, eventId: event.eventId, source: 'vp', node: '', competency,
281
+ date: event.date, signal, summary: `${problem.problemId} ${problem.status} · ${competency}`, fingerprint: id })
282
+ }
283
+ }
284
+ for (const record of vp.postmortems) for (const competency of new Set(record.competencies)) {
285
+ const id = `postmortem:${record.eventId}:${competency}`
286
+ result.push({ id, eventId: record.eventId, source: 'postmortem', node: '', competency,
287
+ date: record.date, signal: 'gap', summary: `复盘指出 ${competency}`, fingerprint: id })
288
+ }
289
+ return { ok: true, reject: '', evidence: result }
290
+ } catch (err) { return { ok: false, reject: err.message, evidence: [] } }
291
+ }
292
+
184
293
  function context() {
185
294
  const t = loadTarget()
186
295
  if (!t.contest) return '- 比赛目标:尚未建立;先用 coach_target set,不要把 rating 当成长期目标。'
@@ -194,7 +303,7 @@ export function createStrategyStore({ dataDir, competenciesPath, now = () => new
194
303
  ].filter(Boolean).join('\n')
195
304
  }
196
305
 
197
- return { target, vpImport, postmortem, focus, scope, context, loadTarget, loadVp, loadCompetencies }
306
+ return { target, vpImport, postmortem, focus, scope, route, evidence, context, loadTarget, loadVp, loadCompetencies }
198
307
  }
199
308
 
200
309
  export const normalizeVpEvent = normalizeEvent
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "acmer-coach",
3
3
  "description": "小鲸教练插件:把「位置 + 判断」从小鲸的转述里拿回来。读 MAP/DEPENDS/PROGRESS,给唯一动作、判因、检测。热插拔,删掉即还原。",
4
- "version": "2.0.0",
4
+ "version": "2.1.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/Miantiao1231/acmer-coach.git"
package/verify-shape.mjs CHANGED
@@ -169,6 +169,11 @@ const cases = [
169
169
  ['coach_ping', {}],
170
170
  ['coach_target', { action: 'read' }],
171
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
+ // 真正走一遍「目标 → 动作」硬闸:不属于当前能力的 progress 节点必须拒绝,
173
+ // 再切回无地图能力的读题卡,保留后续旧夹具的成功路径。
174
+ ['coach_target', { action: 'set', expectedRevision: 1, contest: '测试区域赛', date: '2026-10-18', result: '金牌', teamMode: 'team', weeklyHours: 20, priorities: [{ id: 'dp-modeling', weight: 80, reason: '测试硬闸' }] }],
175
+ ['coach_assign#target-gate', { node: 'B', deliverable: '把代码贴给我', why: '测试目标硬闸', minutes: 60, teachMinutes: 25 }],
176
+ ['coach_target', { action: 'set', expectedRevision: 2, contest: '测试区域赛', date: '2026-10-18', result: '金牌', teamMode: 'team', weeklyHours: 20, priorities: [{ id: 'contest-reading', weight: 80, reason: '测试目标' }] }],
172
177
  ['coach_vp_import', { events: [{ eventId: 'shape-vp', contest: '测试 VP', date: today, durationMinutes: 300, problems: [{ problemId: 'A', status: 'AC', competencies: ['contest-reading'] }] }] }],
173
178
  ['coach_postmortem', { eventId: 'shape-vp', competencies: ['contest-reading'], rootCauses: ['测试复盘'], nextActions: ['测试动作'] }],
174
179
  ['coach_focus', {}],
@@ -298,6 +303,11 @@ for (const [name, args, preset] of cases) {
298
303
  bad++
299
304
  continue
300
305
  }
306
+ if (name === 'coach_assign#target-gate' && out.accepted !== false) {
307
+ console.log(` FAIL ${name} → 目标硬闸没有拒绝无关 progress 节点`)
308
+ bad++
309
+ continue
310
+ }
301
311
  const errs = []
302
312
  validate(tool.output.schema, out, 'out', errs)
303
313
  let renderNote = ''