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 +2 -3
- package/assets/rules/coach-rules.md +1 -1
- package/dev/curriculum-check.mjs +20 -0
- package/dev/strategy-check.mjs +85 -2
- package/docs//351/225/277/346/234/237/350/257/276/347/250/213/346/234/272/345/210/266.md +7 -4
- package/index.js +55 -10
- package/lib/curriculum-planning.js +15 -4
- package/lib/curriculum.js +32 -20
- package/lib/strategy.js +123 -14
- package/package.json +1 -1
- package/verify-shape.mjs +10 -0
package/README.md
CHANGED
|
@@ -75,7 +75,7 @@ VP 记录格式是 JSON 数组,每场至少包含 `eventId`、`contest`、`dat
|
|
|
75
75
|
### 长期学习路线
|
|
76
76
|
|
|
77
77
|
`coach_curriculum assess` 从已有检测、代码判因、做题记录和日程实际完成中列出
|
|
78
|
-
|
|
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`,写尚待确认的问题和具体取证方法。
|
package/dev/curriculum-check.mjs
CHANGED
|
@@ -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)
|
package/dev/strategy-check.mjs
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}
|
|
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
|
-
|
|
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
|
-
|
|
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 = () =>
|
|
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
|
-
|
|
26
|
-
|
|
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:
|
|
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
|
|
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: '
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
33
|
-
|
|
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
|
|
98
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
136
|
-
|
|
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
|
-
|
|
159
|
-
|
|
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
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 = ''
|