acmer-coach 1.2.1 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -6
- package/assets/knowledge/COMPETENCIES.yaml +92 -0
- package/assets/rules/coach-rules.md +17 -2
- package/dev/curriculum-check.mjs +20 -0
- package/dev/run-all.sh +2 -0
- package/dev/strategy-check.mjs +158 -0
- package/dev.sh +1 -1
- 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 +171 -8
- package/lib/curriculum-planning.js +15 -4
- package/lib/curriculum.js +32 -20
- package/lib/strategy.js +310 -0
- package/package.json +1 -1
- package/verify-shape.mjs +17 -0
package/lib/strategy.js
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
// Goal-driven strategy layer for acmer-coach.
|
|
2
|
+
// It stores contest targets, VP evidence and postmortems separately from
|
|
3
|
+
// PROGRESS.yaml so the existing knowledge workflow keeps its meaning.
|
|
4
|
+
import { copyFileSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { parse, stringify } from 'yaml'
|
|
7
|
+
|
|
8
|
+
const clean = (value) => String(value ?? '').trim()
|
|
9
|
+
const asArray = (value) => Array.isArray(value) ? value : []
|
|
10
|
+
const isoDay = (value) => {
|
|
11
|
+
const day = clean(value)
|
|
12
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) return ''
|
|
13
|
+
const d = new Date(`${day}T12:00:00Z`)
|
|
14
|
+
return Number.isFinite(d.getTime()) && d.toISOString().slice(0, 10) === day ? day : ''
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const emptyTarget = () => ({ version: 1, revision: 0, updated: '', contest: '', date: '', result: '', teamMode: 'solo', weeklyHours: 0, constraints: {}, priorities: [] })
|
|
18
|
+
const emptyVp = () => ({ version: 1, revision: 0, updated: '', events: [], postmortems: [] })
|
|
19
|
+
|
|
20
|
+
function readYaml(path, fallback) {
|
|
21
|
+
try {
|
|
22
|
+
const value = parse(readFileSync(path, 'utf8'))
|
|
23
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : fallback()
|
|
24
|
+
} catch (err) {
|
|
25
|
+
if (err.code === 'ENOENT') return fallback()
|
|
26
|
+
throw err
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function writeYaml(path, header, value) {
|
|
31
|
+
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`
|
|
32
|
+
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
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function normalizePriority(value) {
|
|
51
|
+
if (!value || typeof value !== 'object') return null
|
|
52
|
+
const id = clean(value.id || value.competency)
|
|
53
|
+
const weight = Number(value.weight)
|
|
54
|
+
if (!id || !Number.isFinite(weight) || weight <= 0) return null
|
|
55
|
+
return { id, weight: Math.min(100, Math.round(weight)), reason: clean(value.reason) }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function normalizeProblem(value) {
|
|
59
|
+
if (!value || typeof value !== 'object') return null
|
|
60
|
+
const problemId = clean(value.problemId || value.id)
|
|
61
|
+
if (!problemId) return null
|
|
62
|
+
const status = clean(value.status || value.verdict || 'unknown').toUpperCase()
|
|
63
|
+
return {
|
|
64
|
+
problemId,
|
|
65
|
+
status,
|
|
66
|
+
readMinutes: Math.max(0, Number(value.readMinutes) || 0),
|
|
67
|
+
solveMinutes: Math.max(0, Number(value.solveMinutes ?? value.elapsedMinutes) || 0),
|
|
68
|
+
attempts: Math.max(0, Math.trunc(Number(value.attempts) || 0)),
|
|
69
|
+
competencies: asArray(value.competencies || value.skills).map(clean).filter(Boolean).slice(0, 12),
|
|
70
|
+
note: clean(value.note),
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function normalizeEvent(value) {
|
|
75
|
+
if (!value || typeof value !== 'object') return null
|
|
76
|
+
const eventId = clean(value.eventId || value.id)
|
|
77
|
+
const contest = clean(value.contest || value.contestId)
|
|
78
|
+
const date = isoDay(value.date || value.startedAt?.slice?.(0, 10))
|
|
79
|
+
const durationMinutes = Math.max(0, Math.trunc(Number(value.durationMinutes) || 0))
|
|
80
|
+
const problems = asArray(value.problems).map(normalizeProblem).filter(Boolean)
|
|
81
|
+
if (!eventId || !contest || !date || !problems.length) return null
|
|
82
|
+
return {
|
|
83
|
+
eventId, contest, date, durationMinutes,
|
|
84
|
+
virtual: value.virtual !== false,
|
|
85
|
+
teamMode: clean(value.teamMode || 'solo'),
|
|
86
|
+
problems,
|
|
87
|
+
note: clean(value.note),
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function normalizePostmortem(value) {
|
|
92
|
+
if (!value || typeof value !== 'object') return null
|
|
93
|
+
const eventId = clean(value.eventId)
|
|
94
|
+
if (!eventId) return null
|
|
95
|
+
return {
|
|
96
|
+
eventId,
|
|
97
|
+
date: isoDay(value.date) || new Date().toISOString().slice(0, 10),
|
|
98
|
+
competencies: asArray(value.competencies).map(clean).filter(Boolean).slice(0, 12),
|
|
99
|
+
missedReads: asArray(value.missedReads).map(clean).filter(Boolean).slice(0, 20),
|
|
100
|
+
decisionErrors: asArray(value.decisionErrors).map(clean).filter(Boolean).slice(0, 20),
|
|
101
|
+
teamIssues: asArray(value.teamIssues).map(clean).filter(Boolean).slice(0, 20),
|
|
102
|
+
rootCauses: asArray(value.rootCauses).map(clean).filter(Boolean).slice(0, 20),
|
|
103
|
+
nextActions: asArray(value.nextActions).map(clean).filter(Boolean).slice(0, 20),
|
|
104
|
+
note: clean(value.note),
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function createStrategyStore({ dataDir, competenciesPath, now = () => new Date() }) {
|
|
109
|
+
const targetPath = join(dataDir, 'TARGET.yaml')
|
|
110
|
+
const vpPath = join(dataDir, 'VP_EVENTS.yaml')
|
|
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
|
+
}
|
|
142
|
+
const saveTarget = (doc) => writeYaml(targetPath, '# TARGET.yaml — 比赛目标与能力优先级\n', doc)
|
|
143
|
+
const saveVp = (doc) => writeYaml(vpPath, '# VP_EVENTS.yaml — VP 记录与赛后复盘\n', doc)
|
|
144
|
+
const loadCompetencies = () => readYaml(competenciesPath, () => ({ version: 1, competencies: [] })).competencies ?? []
|
|
145
|
+
|
|
146
|
+
function target(action = 'read', args = {}) {
|
|
147
|
+
let old
|
|
148
|
+
try { old = loadTarget() } catch (err) {
|
|
149
|
+
return { ok: false, reject: err.message, ...emptyTarget() }
|
|
150
|
+
}
|
|
151
|
+
if (action === 'read') return { ok: true, reject: '', ...old }
|
|
152
|
+
if (!['set', 'clear'].includes(action)) return { ok: false, reject: 'action 只能是 read/set/clear。', ...old }
|
|
153
|
+
if (args.expectedRevision !== old.revision) return { ok: false, reject: '目标版本已变化;先 read,再按当前 revision 更新。', ...old }
|
|
154
|
+
if (action === 'clear') {
|
|
155
|
+
const next = { ...emptyTarget(), revision: old.revision + 1, updated: now().toISOString() }
|
|
156
|
+
saveTarget(next)
|
|
157
|
+
return { ok: true, reject: '', ...next }
|
|
158
|
+
}
|
|
159
|
+
const date = clean(args.date)
|
|
160
|
+
if (!clean(args.contest) || !isoDay(date) || !clean(args.result)) return { ok: false, reject: 'set 必须提供 contest、真实 YYYY-MM-DD 的 date 和 result。', ...old }
|
|
161
|
+
const weeklyHours = Number(args.weeklyHours)
|
|
162
|
+
if (!Number.isFinite(weeklyHours) || weeklyHours <= 0 || weeklyHours > 168) return { ok: false, reject: 'weeklyHours 必须在 0 和 168 之间。', ...old }
|
|
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 }
|
|
166
|
+
if (!priorities.length) return { ok: false, reject: '至少给一个能力优先级:id + weight。', ...old }
|
|
167
|
+
const known = new Set(loadCompetencies().map((item) => item.id))
|
|
168
|
+
const unknown = priorities.map((item) => item.id).filter((id) => !known.has(id))
|
|
169
|
+
if (unknown.length) return { ok: false, reject: `能力优先级没有范围卡片:${unknown.join('、')}。先补充 COMPETENCIES.yaml。`, ...old }
|
|
170
|
+
const teamMode = clean(args.teamMode || old.teamMode || 'solo')
|
|
171
|
+
if (!['solo', 'team'].includes(teamMode)) return { ok: false, reject: 'teamMode 只能是 solo 或 team。', ...old }
|
|
172
|
+
const next = {
|
|
173
|
+
...old, contest: clean(args.contest), date, result: clean(args.result),
|
|
174
|
+
teamMode, weeklyHours,
|
|
175
|
+
constraints: args.constraints && typeof args.constraints === 'object' ? args.constraints : old.constraints,
|
|
176
|
+
priorities, revision: old.revision + 1, updated: now().toISOString(),
|
|
177
|
+
}
|
|
178
|
+
saveTarget(next)
|
|
179
|
+
return { ok: true, reject: '', ...next }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function vpImport(events = []) {
|
|
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 }
|
|
191
|
+
const existing = new Map(old.events.map((event) => [event.eventId, event]))
|
|
192
|
+
for (const event of normalized) existing.set(event.eventId, event)
|
|
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() }
|
|
194
|
+
saveVp(next)
|
|
195
|
+
return { ok: true, reject: '', imported: normalized.length, total: next.events.length, revision: next.revision, events: next.events, invalid: [] }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function postmortem(value) {
|
|
199
|
+
const eventId = clean(value?.eventId)
|
|
200
|
+
const vp = loadVp()
|
|
201
|
+
if (!vp.events.some((event) => event.eventId === eventId)) return { ok: false, reject: `找不到 VP「${eventId}」,先导入这场 VP。`, postmortems: vp.postmortems }
|
|
202
|
+
const record = normalizePostmortem(value)
|
|
203
|
+
if (!record || (!record.competencies.length && !record.rootCauses.length && !record.nextActions.length)) return { ok: false, reject: '复盘至少要写 competencies、rootCauses 或 nextActions 之一。', postmortems: vp.postmortems }
|
|
204
|
+
const list = vp.postmortems.filter((item) => item.eventId !== eventId)
|
|
205
|
+
list.push(record)
|
|
206
|
+
const next = { ...vp, postmortems: list, revision: vp.revision + 1, updated: now().toISOString() }
|
|
207
|
+
saveVp(next)
|
|
208
|
+
return { ok: true, reject: '', eventId, postmortems: next.postmortems, revision: next.revision }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function focus() {
|
|
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
|
+
}
|
|
222
|
+
const catalog = loadCompetencies()
|
|
223
|
+
if (!targetDoc.contest) return {
|
|
224
|
+
ok: false, reject: '还没有比赛目标,先用 coach_target set。', contest: '', deadline: '',
|
|
225
|
+
focus: [], postponed: [], evidenceEvents: vp.events.length, postmortems: vp.postmortems.length,
|
|
226
|
+
}
|
|
227
|
+
const postmortemHits = new Map()
|
|
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
|
+
}
|
|
235
|
+
const focus = targetDoc.priorities.map((priority) => {
|
|
236
|
+
const meta = catalog.find((item) => item.id === priority.id) ?? { id: priority.id, title: priority.id, contestUse: '暂无比赛能力卡片,先补充定义。', nodes: [] }
|
|
237
|
+
const evidence = (postmortemHits.get(priority.id) ?? 0) + (problemHits.get(priority.id) ?? 0)
|
|
238
|
+
const urgency = Math.max(1, 30 - Math.floor((Date.parse(`${targetDoc.date}T12:00:00Z`) - Date.now()) / 86400000))
|
|
239
|
+
const score = Math.round(priority.weight * (1 + evidence) * urgency / 10) / 10
|
|
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 证据。') }
|
|
241
|
+
}).sort((a, b) => b.score - a.score)
|
|
242
|
+
return { ok: true, reject: '', contest: targetDoc.contest, deadline: targetDoc.date, focus: focus.slice(0, 3), postponed: focus.slice(3).map((item) => item.id), evidenceEvents: vp.events.length, postmortems: vp.postmortems.length }
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function scope(nodeOrId) {
|
|
246
|
+
const catalog = loadCompetencies()
|
|
247
|
+
const direct = catalog.find((item) => item.id === nodeOrId || item.nodes?.includes(nodeOrId))
|
|
248
|
+
if (direct) return { ok: true, reject: '', found: true, ...direct }
|
|
249
|
+
return { ok: false, reject: `「${nodeOrId}」还没有范围卡片,不能凭空编造适用范围;先补充 COMPETENCIES.yaml。`, found: false, id: nodeOrId, title: nodeOrId, contestUse: '', nodes: [], appliesTo: [], extensions: [], notFor: [], commonFailures: [], transferProblems: [], exitEvidence: [] }
|
|
250
|
+
}
|
|
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
|
+
|
|
293
|
+
function context() {
|
|
294
|
+
const t = loadTarget()
|
|
295
|
+
if (!t.contest) return '- 比赛目标:尚未建立;先用 coach_target set,不要把 rating 当成长期目标。'
|
|
296
|
+
const f = focus()
|
|
297
|
+
return [
|
|
298
|
+
`- 比赛目标:${t.contest},目标日期 ${t.date},结果目标 ${t.result},团队模式 ${t.teamMode}`,
|
|
299
|
+
`- 当前目标权重:${t.priorities.map((p) => `${p.id}(${p.weight})`).join('、')}`,
|
|
300
|
+
f.focus?.length ? `- 本轮优先能力:${f.focus.map((x) => `${x.title}(${x.whyNow})`).join(';')}` : '- 当前没有可计算的能力重点。',
|
|
301
|
+
f.postponed?.length ? `- 暂缓能力:${f.postponed.join('、')}` : '',
|
|
302
|
+
'- 每次布置前先说明它服务哪个比赛能力、适用范围、迁移题和退出条件。',
|
|
303
|
+
].filter(Boolean).join('\n')
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return { target, vpImport, postmortem, focus, scope, route, evidence, context, loadTarget, loadVp, loadCompetencies }
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export const normalizeVpEvent = normalizeEvent
|
|
310
|
+
export const normalizeVpPostmortem = normalizePostmortem
|
package/package.json
CHANGED
package/verify-shape.mjs
CHANGED
|
@@ -167,6 +167,18 @@ const today = new Date().toLocaleDateString('sv-SE') // YYYY-MM-DD(本地)
|
|
|
167
167
|
// 每个工具至少两条路:走得通的 + 被拒的(**被拒的那条最容易形状不齐**)
|
|
168
168
|
const cases = [
|
|
169
169
|
['coach_ping', {}],
|
|
170
|
+
['coach_target', { action: 'read' }],
|
|
171
|
+
['coach_target', { action: 'set', expectedRevision: 0, contest: '测试区域赛', date: '2026-10-18', result: '金牌', teamMode: 'team', weeklyHours: 20, priorities: [{ id: 'contest-reading', weight: 80, reason: '测试目标' }] }],
|
|
172
|
+
// 真正走一遍「目标 → 动作」硬闸:不属于当前能力的 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: '测试目标' }] }],
|
|
177
|
+
['coach_vp_import', { events: [{ eventId: 'shape-vp', contest: '测试 VP', date: today, durationMinutes: 300, problems: [{ problemId: 'A', status: 'AC', competencies: ['contest-reading'] }] }] }],
|
|
178
|
+
['coach_postmortem', { eventId: 'shape-vp', competencies: ['contest-reading'], rootCauses: ['测试复盘'], nextActions: ['测试动作'] }],
|
|
179
|
+
['coach_focus', {}],
|
|
180
|
+
['coach_scope', { id: 'contest-reading' }],
|
|
181
|
+
['coach_scope', { id: '不存在的能力' }],
|
|
170
182
|
// 无课程的读取 / 无课程的推进拒绝,不建立课程以免改变后续旧工具夹具。
|
|
171
183
|
['coach_curriculum', { action: 'read' }],
|
|
172
184
|
['coach_curriculum', { action: 'assess' }],
|
|
@@ -291,6 +303,11 @@ for (const [name, args, preset] of cases) {
|
|
|
291
303
|
bad++
|
|
292
304
|
continue
|
|
293
305
|
}
|
|
306
|
+
if (name === 'coach_assign#target-gate' && out.accepted !== false) {
|
|
307
|
+
console.log(` FAIL ${name} → 目标硬闸没有拒绝无关 progress 节点`)
|
|
308
|
+
bad++
|
|
309
|
+
continue
|
|
310
|
+
}
|
|
294
311
|
const errs = []
|
|
295
312
|
validate(tool.output.schema, out, 'out', errs)
|
|
296
313
|
let renderNote = ''
|