acmer-coach 2.3.1 → 2.5.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/lib/strategy.js CHANGED
@@ -2,11 +2,17 @@
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
4
  import { copyFileSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
5
- import { join } from 'node:path'
5
+ import { dirname, join } from 'node:path'
6
6
  import { parse, stringify } from 'yaml'
7
+ import { createHash } from 'node:crypto'
8
+ import { validOccurredAt } from './curriculum-evidence.js'
7
9
 
8
10
  const clean = (value) => String(value ?? '').trim()
9
11
  const asArray = (value) => Array.isArray(value) ? value : []
12
+ const accepted = new Set(['AC', 'OK', 'ACCEPTED', 'SOLVED'])
13
+ const localDay = (value) => `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, '0')}-${String(value.getDate()).padStart(2, '0')}`
14
+ const nonnegative = (value, integer = false) => value === undefined ||
15
+ (typeof value === 'number' && Number.isFinite(value) && value >= 0 && (!integer || Number.isInteger(value)))
10
16
  const isoDay = (value) => {
11
17
  const day = clean(value)
12
18
  if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) return ''
@@ -14,7 +20,7 @@ const isoDay = (value) => {
14
20
  return Number.isFinite(d.getTime()) && d.toISOString().slice(0, 10) === day ? day : ''
15
21
  }
16
22
 
17
- const emptyTarget = () => ({ version: 1, revision: 0, updated: '', contest: '', date: '', result: '', teamMode: 'solo', weeklyHours: 0, constraints: {}, priorities: [] })
23
+ const emptyTarget = () => ({ version: 1, revision: 0, updated: '', contest: '', date: '', result: '', teamMode: 'solo', weeklyHours: 0, profileId: '', constraints: {}, priorities: [] })
18
24
  const emptyVp = () => ({ version: 1, revision: 0, updated: '', events: [], postmortems: [] })
19
25
 
20
26
  function readYaml(path, fallback) {
@@ -59,32 +65,51 @@ function normalizeProblem(value) {
59
65
  if (!value || typeof value !== 'object') return null
60
66
  const problemId = clean(value.problemId || value.id)
61
67
  if (!problemId) return null
68
+ if (!nonnegative(value.readMinutes) || !nonnegative(value.solveMinutes ?? value.elapsedMinutes) || !nonnegative(value.attempts, true) ||
69
+ (value.independent !== undefined && typeof value.independent !== 'boolean') ||
70
+ (value.occurredAt !== undefined && !validOccurredAt(value.occurredAt))) return null
62
71
  const status = clean(value.status || value.verdict || 'unknown').toUpperCase()
63
72
  return {
64
73
  problemId,
65
74
  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)),
75
+ readMinutes: value.readMinutes ?? 0,
76
+ solveMinutes: value.solveMinutes ?? value.elapsedMinutes ?? 0,
77
+ attempts: value.attempts ?? 0,
69
78
  competencies: asArray(value.competencies || value.skills).map(clean).filter(Boolean).slice(0, 12),
70
79
  note: clean(value.note),
80
+ ...(typeof value.independent === 'boolean' ? { independent: value.independent } : {}),
81
+ ...(typeof value.occurredAt === 'string' && Number.isFinite(Date.parse(value.occurredAt)) ? { occurredAt: value.occurredAt } : {}),
71
82
  }
72
83
  }
73
84
 
85
+ function normalizeMeasurement(value) {
86
+ if (!value || typeof value !== 'object' || !clean(value.competency) || !clean(value.metric) ||
87
+ typeof value.value !== 'number' || !Number.isFinite(value.value) || typeof value.passed !== 'boolean' ||
88
+ (value.independent !== undefined && typeof value.independent !== 'boolean') ||
89
+ (value.occurredAt !== undefined && !validOccurredAt(value.occurredAt))) return null
90
+ return { competency: clean(value.competency), metric: clean(value.metric), value: value.value, passed: value.passed,
91
+ ...(value.independent !== undefined ? { independent: value.independent } : {}),
92
+ ...(value.occurredAt !== undefined ? { occurredAt: value.occurredAt } : {}) }
93
+ }
94
+
74
95
  function normalizeEvent(value) {
75
96
  if (!value || typeof value !== 'object') return null
76
97
  const eventId = clean(value.eventId || value.id)
77
98
  const contest = clean(value.contest || value.contestId)
78
99
  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
100
+ const durationMinutes = value.durationMinutes ?? 0
101
+ const problems = asArray(value.problems).map(normalizeProblem)
102
+ const measurements = asArray(value.measurements).map(normalizeMeasurement)
103
+ if (!eventId || !contest || !date || !nonnegative(durationMinutes, true) || !problems.length || problems.some(problem => !problem) ||
104
+ (value.measurements !== undefined && (!Array.isArray(value.measurements) || measurements.some(item => !item) ||
105
+ new Set(measurements.map(item => `${item.competency}:${item.metric}`)).size !== measurements.length))) return null
82
106
  return {
83
107
  eventId, contest, date, durationMinutes,
84
108
  virtual: value.virtual !== false,
85
109
  teamMode: clean(value.teamMode || 'solo'),
86
110
  problems,
87
111
  note: clean(value.note),
112
+ ...(value.measurements !== undefined ? { measurements } : {}),
88
113
  }
89
114
  }
90
115
 
@@ -102,10 +127,42 @@ function normalizePostmortem(value) {
102
127
  rootCauses: asArray(value.rootCauses).map(clean).filter(Boolean).slice(0, 20),
103
128
  nextActions: asArray(value.nextActions).map(clean).filter(Boolean).slice(0, 20),
104
129
  note: clean(value.note),
130
+ findings: asArray(value.findings).map(finding => ({ competency: clean(finding?.competency),
131
+ status: clean(finding?.status), cause: clean(finding?.cause), evidenceRefs: asArray(finding?.evidenceRefs).map(clean) })),
132
+ }
133
+ }
134
+
135
+ function vpFacts(vp) {
136
+ const result = []
137
+ for (const event of vp.events) for (const input of event.problems) {
138
+ const problem = normalizeProblem(input)
139
+ const hash = createHash('sha256').update(JSON.stringify({ eventId: event.eventId, date: event.date, teamMode: event.teamMode, problem })).digest('hex')
140
+ for (const competency of new Set(problem.competencies.length ? problem.competencies : [''])) {
141
+ const id = `vp:${encodeURIComponent(event.eventId)}:${encodeURIComponent(problem.problemId)}:${encodeURIComponent(competency)}:${hash}`
142
+ result.push({ id, eventId: event.eventId, source: 'vp', node: '', competency, date: event.date,
143
+ signal: accepted.has(problem.status) && problem.independent === true ? 'baseline' : 'observation',
144
+ summary: `${problem.problemId} ${problem.status} · ${competency}`, fingerprint: hash,
145
+ ...(typeof problem.independent === 'boolean' ? { independent: problem.independent } : {}),
146
+ ...(problem.solveMinutes > 0 ? { minutes: problem.solveMinutes } : {}),
147
+ ...(problem.occurredAt ? { occurredAt: problem.occurredAt } : {}), teamMode: event.teamMode })
148
+ }
105
149
  }
150
+ for (const event of vp.events) for (const measurement of event.measurements ?? []) {
151
+ const hash = createHash('sha256').update(JSON.stringify({ eventId: event.eventId, date: event.date, teamMode: event.teamMode, measurement })).digest('hex')
152
+ result.push({ id: `vp-metric:${encodeURIComponent(event.eventId)}:${encodeURIComponent(measurement.metric)}:${hash}`,
153
+ eventId: event.eventId, source: 'vp', node: '', competency: measurement.competency, date: event.date,
154
+ signal: measurement.passed && measurement.independent === true ? 'baseline' : 'observation',
155
+ summary: `${measurement.metric} = ${measurement.value}(${measurement.passed ? '已验收' : '仅观察'})`, fingerprint: hash,
156
+ metric: measurement.metric, value: measurement.value, teamMode: event.teamMode,
157
+ ...(measurement.independent !== undefined ? { independent: measurement.independent } : {}),
158
+ ...(measurement.occurredAt !== undefined ? { occurredAt: measurement.occurredAt } : {}) })
159
+ }
160
+ return result
106
161
  }
107
162
 
108
- export function createStrategyStore({ dataDir, vpDir = dataDir, competenciesPath, now = () => new Date() }) {
163
+ export function createStrategyStore({ dataDir, vpDir = dataDir, competenciesPath,
164
+ nodeScopesPath = join(dirname(competenciesPath), 'NODE_SCOPES.yaml'),
165
+ contestProfilesPath = join(dirname(competenciesPath), 'CONTEST_PROFILES.yaml'), now = () => new Date() }) {
109
166
  const targetPath = join(dataDir, 'TARGET.yaml')
110
167
  const vpPath = join(vpDir, 'VP_EVENTS.yaml')
111
168
  const validateTarget = (doc) => {
@@ -142,19 +199,24 @@ export function createStrategyStore({ dataDir, vpDir = dataDir, competenciesPath
142
199
  const saveTarget = (doc) => writeYaml(targetPath, '# TARGET.yaml — 比赛目标与能力优先级\n', doc)
143
200
  const saveVp = (doc) => writeYaml(vpPath, '# VP_EVENTS.yaml — VP 记录与赛后复盘\n', doc)
144
201
  const loadCompetencies = () => readYaml(competenciesPath, () => ({ version: 1, competencies: [] })).competencies ?? []
202
+ const loadProfiles = () => readYaml(contestProfilesPath, () => ({ profiles: [] })).profiles ?? []
203
+ const profileFor = id => loadProfiles().find(item => item.id === id) ?? { id: '', title: '', scope: '', status: 'unselected',
204
+ sources: [], competencyIds: [], selectionGuidance: [], limitations: ['未选择比赛样本配置;不能把通用能力卡当作本场考纲。'] }
145
205
 
146
206
  function target(action = 'read', args = {}) {
147
207
  let old
148
208
  try { old = loadTarget() } catch (err) {
149
- return { ok: false, reject: err.message, ...emptyTarget() }
209
+ return { ok: false, reject: err.message, ...emptyTarget(), availableProfiles: [] }
150
210
  }
151
- if (action === 'read') return { ok: true, reject: '', ...old }
152
- if (!['set', 'clear'].includes(action)) return { ok: false, reject: 'action 只能是 read/set/clear。', ...old }
211
+ const availableProfiles = action === 'profiles' ? loadProfiles() : []
212
+ old.availableProfiles = availableProfiles
213
+ if (['read', 'profiles'].includes(action)) return { ok: true, reject: '', ...old }
214
+ if (!['set', 'clear'].includes(action)) return { ok: false, reject: 'action 只能是 read/profiles/set/clear。', ...old }
153
215
  if (args.expectedRevision !== old.revision) return { ok: false, reject: '目标版本已变化;先 read,再按当前 revision 更新。', ...old }
154
216
  if (action === 'clear') {
155
217
  const next = { ...emptyTarget(), revision: old.revision + 1, updated: now().toISOString() }
156
218
  saveTarget(next)
157
- return { ok: true, reject: '', ...next }
219
+ return { ok: true, reject: '', ...next, availableProfiles: [] }
158
220
  }
159
221
  const date = clean(args.date)
160
222
  if (!clean(args.contest) || !isoDay(date) || !clean(args.result)) return { ok: false, reject: 'set 必须提供 contest、真实 YYYY-MM-DD 的 date 和 result。', ...old }
@@ -169,26 +231,42 @@ export function createStrategyStore({ dataDir, vpDir = dataDir, competenciesPath
169
231
  if (unknown.length) return { ok: false, reject: `能力优先级没有范围卡片:${unknown.join('、')}。先补充 COMPETENCIES.yaml。`, ...old }
170
232
  const teamMode = clean(args.teamMode || old.teamMode || 'solo')
171
233
  if (!['solo', 'team'].includes(teamMode)) return { ok: false, reject: 'teamMode 只能是 solo 或 team。', ...old }
234
+ const profileId = args.profileId === undefined ? old.profileId : clean(args.profileId)
235
+ if (profileId && !loadProfiles().some(item => item.id === profileId)) return { ok: false, reject: 'profileId 不存在;先用 coach_target profiles 查看带出处的样本配置。', ...old }
236
+ if (args.constraints !== undefined && (!args.constraints || typeof args.constraints !== 'object' || Array.isArray(args.constraints))) return { ok: false, reject: 'constraints 必须是对象;不会修改现有目标。', ...old }
172
237
  const next = {
173
- ...old, contest: clean(args.contest), date, result: clean(args.result),
238
+ ...old, contest: clean(args.contest), date, result: clean(args.result), profileId,
174
239
  teamMode, weeklyHours,
175
240
  constraints: args.constraints && typeof args.constraints === 'object' ? args.constraints : old.constraints,
176
241
  priorities, revision: old.revision + 1, updated: now().toISOString(),
177
242
  }
243
+ delete next.availableProfiles
178
244
  saveTarget(next)
179
- return { ok: true, reject: '', ...next }
245
+ return { ok: true, reject: '', ...next, availableProfiles: [] }
180
246
  }
181
247
 
182
- function vpImport(events = []) {
248
+ function vpImport(events = [], { source = 'manual' } = {}) {
183
249
  let old
184
250
  try { old = loadVp() } catch (err) {
185
251
  return { ok: false, reject: err.message, imported: 0, total: 0, revision: 0, events: [], invalid: [] }
186
252
  }
187
253
  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
254
  const existing = new Map(old.events.map((event) => [event.eventId, event]))
255
+ const inputs = events.map(event => {
256
+ const previous = existing.get(clean(event?.eventId || event?.id))
257
+ if (source !== 'sync' || !previous) return event
258
+ return { ...event, ...(previous.measurements ? { measurements: previous.measurements } : {}), problems: asArray(event.problems).map(problem => {
259
+ const prior = previous.problems.find(item => item.problemId === clean(problem?.problemId || problem?.id))
260
+ if (!prior) return problem
261
+ return { ...problem, competencies: prior.competencies, readMinutes: prior.readMinutes, note: prior.note,
262
+ ...(prior.independent !== undefined ? { independent: prior.independent } : {}) }
263
+ }) }
264
+ })
265
+ const normalized = inputs.map(normalizeEvent)
266
+ const known = new Set(loadCompetencies().map(item => item.id))
267
+ const invalid = normalized.map((event, index) => event && !(event.measurements ?? []).some(item => !known.has(item.competency)) ? '' :
268
+ `第 ${index + 1} 条 VP 记录有无效身份、日期、题目、数值或测量;用时非负,次数为整数,measurement需已知competency/metric/有限value/passed`).filter(Boolean)
269
+ if (invalid.length) return { ok: false, reject: 'VP 导入被拒绝:' + invalid.join(';'), imported: 0, total: old.events.length, revision: old.revision, events: old.events, invalid }
192
270
  for (const event of normalized) existing.set(event.eventId, event)
193
271
  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
272
  saveVp(next)
@@ -198,9 +276,27 @@ export function createStrategyStore({ dataDir, vpDir = dataDir, competenciesPath
198
276
  function postmortem(value) {
199
277
  const eventId = clean(value?.eventId)
200
278
  const vp = loadVp()
201
- if (!vp.events.some((event) => event.eventId === eventId)) return { ok: false, reject: `找不到 VP「${eventId}」,先导入这场 VP。`, postmortems: vp.postmortems }
279
+ const reject = message => ({ ok: false, reject: message, eventId, revision: vp.revision, postmortems: vp.postmortems })
280
+ const event = vp.events.find(event => event.eventId === eventId)
281
+ if (!event) return reject(`找不到 VP「${eventId}」,先导入这场 VP。`)
202
282
  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 }
283
+ if (!record || (!record.competencies.length && !record.rootCauses.length && !record.nextActions.length && !record.findings.length)) return reject('复盘至少要写 findings、competencies、rootCauses 或 nextActions 之一。')
284
+ if (value.findings !== undefined && (!Array.isArray(value.findings) || value.findings.length > 30)) return reject('findings 必须是最多30条结论的数组。')
285
+ const facts = vpFacts(vp)
286
+ const known = new Set(loadCompetencies().map(item => item.id))
287
+ const seen = new Set()
288
+ for (const finding of record.findings) {
289
+ if (!known.has(finding.competency) || seen.has(finding.competency)) return reject('每项 finding 必须对应已知且不重复的比赛能力。')
290
+ seen.add(finding.competency)
291
+ if (!['hypothesis', 'confirmed', 'resolved'].includes(finding.status) || finding.cause.length < 10) return reject('finding 需 hypothesis/confirmed/resolved 和至少10字的具体原因;未确认时只用 hypothesis。')
292
+ if (!Array.isArray(value.findings.find(item => item?.competency === finding.competency)?.evidenceRefs) ||
293
+ new Set(finding.evidenceRefs).size !== finding.evidenceRefs.length || !finding.evidenceRefs.length) return reject('finding 必须引用不重复的事实 evidenceRefs;先 coach_curriculum assess 取引用。')
294
+ const cited = finding.evidenceRefs.map(id => facts.find(fact => fact.id === id && fact.eventId === eventId && (!fact.competency || fact.competency === finding.competency)))
295
+ if (cited.some(fact => !fact)) return reject('finding 引用了不存在、已变化、其它场次或无关能力的事实。')
296
+ if (finding.status === 'resolved' && !cited.some(fact => fact.signal === 'baseline')) return reject('resolved 需要真实独立成功的记录,不能把猜测或失败改成成功。')
297
+ }
298
+ // A retrospective is dated by its observed event, not by the day somebody edits it.
299
+ record.date = event.date
204
300
  const list = vp.postmortems.filter((item) => item.eventId !== eventId)
205
301
  list.push(record)
206
302
  const next = { ...vp, postmortems: list, revision: vp.revision + 1, updated: now().toISOString() }
@@ -216,75 +312,77 @@ export function createStrategyStore({ dataDir, vpDir = dataDir, competenciesPath
216
312
  } catch (err) {
217
313
  return {
218
314
  ok: false, reject: err.message, contest: '', deadline: '', focus: [], postponed: [],
219
- evidenceEvents: 0, postmortems: 0,
315
+ evidenceEvents: 0, postmortems: 0, profile: profileFor(''), daysRemaining: 0,
220
316
  }
221
317
  }
222
318
  const catalog = loadCompetencies()
223
319
  if (!targetDoc.contest) return {
224
320
  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
- }
321
+ focus: [], postponed: [], evidenceEvents: vp.events.length, postmortems: vp.postmortems.length, profile: profileFor(''), daysRemaining: 0,
234
322
  }
323
+ const day = localDay(now())
324
+ const cutoff = new Date(Date.parse(`${day}T12:00:00Z`) - 29 * 86400000).toISOString().slice(0, 10)
325
+ const recent = evidence().evidence.filter(entry => entry.date >= cutoff && entry.date <= day)
235
326
  const focus = targetDoc.priorities.map((priority) => {
236
327
  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 证据。') }
328
+ const entries = recent.filter(entry => entry.competency === priority.id)
329
+ const count = signal => new Set(entries.filter(entry => entry.signal === signal).map(entry => entry.eventId)).size
330
+ const gapCount = count('gap'), baselineCount = count('baseline'), observationCount = count('observation')
331
+ const evidenceCount = new Set(entries.map(entry => entry.eventId)).size
332
+ const score = priority.weight * (1 + gapCount)
333
+ const whyNow = gapCount ? `近30天有 ${gapCount} 场已确认的相关短板;结合目标权重优先处理。`
334
+ : baselineCount ? `近30天有 ${baselineCount} 场独立完成的能力起点;不是待修复问题,是否进阶需核对迁移与限时表现。`
335
+ : observationCount ? `近30天有 ${observationCount} 场相关观察,原因尚待确认;先诊断,不能直接安排补短板。`
336
+ : (priority.reason || '它是目标中的高权重能力,目前缺少近期 VP 证据;先取证。')
337
+ return { id: priority.id, title: meta.title ?? priority.id, score, evidenceCount, gapCount, baselineCount, observationCount,
338
+ evidenceRefs: entries.map(entry => entry.id), contestUse: meta.contestUse ?? '', nodes: meta.nodes ?? [], whyNow }
241
339
  }).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 }
340
+ const daysRemaining = Math.round((Date.parse(`${targetDoc.date}T12:00:00Z`) - Date.parse(`${day}T12:00:00Z`)) / 86400000)
341
+ return { ok: true, reject: '', contest: targetDoc.contest, deadline: targetDoc.date, focus: focus.slice(0, 3), postponed: focus.slice(3).map((item) => item.id),
342
+ evidenceEvents: vp.events.length, postmortems: vp.postmortems.length, profile: profileFor(targetDoc.profileId), daysRemaining }
243
343
  }
244
344
 
245
345
  function scope(nodeOrId) {
246
346
  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: [] }
347
+ const nodeCards = readYaml(nodeScopesPath, () => ({ nodes: [] })).nodes ?? []
348
+ const direct = nodeCards.find(item => item.id === nodeOrId) ?? catalog.find(item => item.id === nodeOrId || item.nodes?.includes(nodeOrId))
349
+ const base = { id: nodeOrId, title: nodeOrId, contestUse: '', nodes: [], appliesTo: [], extensions: [], notFor: [],
350
+ commonFailures: [], transferProblems: [], exitEvidence: [], recognition: [], proofQuestions: [], sources: [] }
351
+ if (direct) return { ok: true, reject: '', found: true, ...base, ...direct }
352
+ return { ok: false, reject: `「${nodeOrId}」还没有范围卡片,不能凭空编造适用范围;先补充 NODE_SCOPES.yaml 或 COMPETENCIES.yaml。`, found: false, ...base }
250
353
  }
251
354
 
252
355
  function route(nodeOrId, { purpose = 'progress', courseBound = false } = {}) {
253
356
  const node = clean(nodeOrId)
254
357
  let targetDoc
255
358
  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 }
359
+ const base = { node, purpose, courseBound: Boolean(courseBound), targetAligned: false, competencyIds: [] }
360
+ if (!targetDoc.contest) return { ok: true, reject: '', ...base }
258
361
  const catalog = loadCompetencies()
259
362
  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
363
  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) }
364
+ // 能力映射只报事实;优先级排名不能替教练决定这一课。
365
+ return { ok: true, reject: '', ...base, targetAligned: targetCards.length > 0, competencyIds: targetCards.map((item) => item.id) }
269
366
  }
270
367
 
271
368
  function evidence() {
272
369
  try {
273
370
  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
- }
371
+ const result = vpFacts(vp)
284
372
  for (const record of vp.postmortems) for (const competency of new Set(record.competencies)) {
285
373
  const id = `postmortem:${record.eventId}:${competency}`
286
374
  result.push({ id, eventId: record.eventId, source: 'postmortem', node: '', competency,
287
- date: record.date, signal: 'gap', summary: `复盘指出 ${competency}`, fingerprint: id })
375
+ date: record.date, signal: 'observation', summary: `复盘提及 ${competency},原因尚待核实`, fingerprint: id })
376
+ }
377
+ const facts = new Map(result.filter(entry => entry.source === 'vp').map(entry => [entry.id, entry]))
378
+ for (const record of vp.postmortems) for (const finding of record.findings ?? []) {
379
+ const cited = finding.evidenceRefs.map(id => facts.get(id))
380
+ if (!cited.length || cited.some(fact => !fact || fact.eventId !== record.eventId || (fact.competency && fact.competency !== finding.competency))) continue
381
+ const hash = createHash('sha256').update(JSON.stringify(finding)).digest('hex')
382
+ result.push({ id: `finding:${encodeURIComponent(record.eventId)}:${finding.competency}:${hash}`, eventId: record.eventId,
383
+ source: 'postmortem', node: '', competency: finding.competency, date: cited[0].date,
384
+ signal: finding.status === 'confirmed' ? 'gap' : 'observation',
385
+ summary: `${finding.status === 'confirmed' ? '复盘已确认' : finding.status === 'resolved' ? '复盘已解决' : '待确认假设'}:${finding.cause}`, fingerprint: hash })
288
386
  }
289
387
  return { ok: true, reject: '', evidence: result }
290
388
  } catch (err) { return { ok: false, reject: err.message, evidence: [] } }
@@ -296,10 +394,14 @@ export function createStrategyStore({ dataDir, vpDir = dataDir, competenciesPath
296
394
  const f = focus()
297
395
  return [
298
396
  `- 比赛目标:${t.contest},目标日期 ${t.date},结果目标 ${t.result},团队模式 ${t.teamMode}`,
397
+ `- 距目标日期:${f.daysRemaining}天;剩余时间影响训练取舍,不能只给所有能力乘同一紧急系数。`,
398
+ `- 比赛样本配置:${f.profile.title || '尚未选择'}(${f.profile.status});${f.profile.limitations.join(';')}`,
299
399
  `- 当前目标权重:${t.priorities.map((p) => `${p.id}(${p.weight})`).join('、')}`,
300
400
  f.focus?.length ? `- 本轮优先能力:${f.focus.map((x) => `${x.title}(${x.whyNow})`).join(';')}` : '- 当前没有可计算的能力重点。',
301
401
  f.postponed?.length ? `- 暂缓能力:${f.postponed.join('、')}` : '',
302
402
  '- 每次布置前先说明它服务哪个比赛能力、适用范围、迁移题和退出条件。',
403
+ '- 重点排序是目标权重与近30天已确认问题的参考;不是掌握度或金牌概率。先区分观察、假设、确认结论。',
404
+ '- 按长期能力目标→本次比赛目标→当前阶段→今天一个动作解释取舍;临近比赛不擅自覆盖长期目标。',
303
405
  ].filter(Boolean).join('\n')
304
406
  }
305
407
 
@@ -0,0 +1,78 @@
1
+ /** Short lesson continuity notes; teaching judgments never change mastery records. */
2
+ export function createTeachingNotes({ loadProgress, saveProgress, loadMap, getSessionQuery, now = () => new Date().toISOString() }) {
3
+ const failure = (reject, node = '') => ({ ok: false, reject, node, notes: [], total: 0 })
4
+ const publicNote = ({ studentEvidence, ...note }) => studentEvidence ? { ...note, studentEvidence } : note
5
+ const mostRecentNoteNode = (progress, ids) => {
6
+ let latest = '', latestAt = ''
7
+ for (const id of ids) {
8
+ const at = progress.nodes[id]?.lessonNotes?.at(-1)?.at
9
+ if (at && at >= latestAt) { latest = id; latestAt = at }
10
+ }
11
+ return latest
12
+ }
13
+ const resolveNode = (progress, node) => {
14
+ if (node !== undefined) return typeof node === 'string' ? node.trim() : ''
15
+ return mostRecentNoteNode(progress, Object.keys(progress.pendingActions || {}))
16
+ || progress.cursor || mostRecentNoteNode(progress, Object.keys(progress.nodes))
17
+ }
18
+ const meaningful = (value, max) => typeof value === 'string' && value.trim().length > 0 && value.length <= max
19
+
20
+ function read({ node, limit = 5 } = {}) {
21
+ if (!Number.isInteger(limit) || limit < 1 || limit > 20) return failure('读取条数须为 1 到 20 的整数。')
22
+ const progress = loadProgress()
23
+ node = resolveNode(progress, node)
24
+ if (!node || !loadMap().byId.has(node)) return failure('请指定有效的知识点或先设置游标。', node)
25
+ const notes = progress.nodes[node]?.lessonNotes || []
26
+ return { ok: true, reject: '', node, notes: notes.slice(-limit).map(publicNote), total: notes.length }
27
+ }
28
+
29
+ async function write({ node, summary, nextStep, studentQuote }, exec) {
30
+ if (!meaningful(summary, 1000) || !meaningful(nextStep, 500)) return failure('教学摘要和下次接续必须有内容,分别最多 1000 和 500 字。')
31
+ if (studentQuote !== undefined && !meaningful(studentQuote, 1500)) return failure('学生原话必须有内容,最多 1500 字。')
32
+ let progress = loadProgress()
33
+ node = node === undefined ? (progress.cursor || '') : typeof node === 'string' ? node.trim() : ''
34
+ if (!node || !loadMap().byId.has(node)) return failure('请指定有效的知识点或先设置游标。', node)
35
+ let studentEvidence = null
36
+ if (studentQuote !== undefined) {
37
+ const sessionId = exec?.agent?.session?.id
38
+ if (!meaningful(sessionId, 500)) return failure('无法核对学生原话:当前调用缺少会话标识。', node)
39
+ try {
40
+ const query = getSessionQuery?.()
41
+ if (typeof query?.readSession !== 'function') return failure('无法核对学生原话:宿主未提供会话查询服务。', node)
42
+ const snapshot = await query.readSession(sessionId)
43
+ if (snapshot?.session?.id && snapshot.session.id !== sessionId) return failure('会话查询返回了其他会话,未写入笔记。', node)
44
+ const event = Array.isArray(snapshot?.events) && snapshot.events.findLast((event) => {
45
+ const message = event.data
46
+ return event.type === 'user/message' && message?.role === 'user' && message.source?.kind === 'user'
47
+ && Number.isInteger(event.seq) && event.seq >= 0 && meaningful(message.id, 500)
48
+ && Array.isArray(message.content) && message.content.filter((block) => block.type === 'text' && typeof block.text === 'string').map((block) => block.text).join('\n').includes(studentQuote)
49
+ })
50
+ if (!event) return failure('当前会话中没有可核对的这段学生原话,未写入笔记。', node)
51
+ studentEvidence = { sessionId, seq: event.seq, messageId: event.data.id, quote: studentQuote }
52
+ } catch {
53
+ return failure('会话查询失败,学生原话尚未核对,未写入笔记。', node)
54
+ }
55
+ // Session lookup yields; keep any progress another tool saved in the meantime.
56
+ progress = loadProgress()
57
+ }
58
+ const record = progress.nodes[node] || {}
59
+ progress.nodes[node] = { ...record, lessonNotes: [...(record.lessonNotes || []), { at: now(), summary: summary.trim(), nextStep: nextStep.trim(), studentEvidence }] }
60
+ saveProgress(progress)
61
+ return read({ node })
62
+ }
63
+
64
+ function context(progress, preferredNode) {
65
+ const node = resolveNode(progress, preferredNode)
66
+ const note = progress.nodes[node]?.lessonNotes?.at(-1)
67
+ if (!note) return ''
68
+ const clip = (text, length) => text.replace(/\s+/g, ' ').slice(0, length)
69
+ const lines = [
70
+ `续课笔记 · ${clip(node, 60)} · ${note.at}`,
71
+ `教练判断:${clip(note.summary, 200)}`,
72
+ note.studentEvidence ? `学生原话已核对(不代表掌握):${clip(note.studentEvidence.quote, 120)}` : '尚无学生原话依据;这是教练的教学观察。',
73
+ `下次接续建议(可调整):${clip(note.nextStep, 220)}`,
74
+ ]
75
+ return lines.join('\n')
76
+ }
77
+ return { read, write, context }
78
+ }
package/lib/vp-client.js CHANGED
@@ -124,10 +124,22 @@ export function createVpClient({ dataDir, fetchImpl = globalThis.fetch, now = ()
124
124
  const payload = await request(current.baseUrl, `/api/coach/sync?${query}`, { token: current.token })
125
125
  const account = verifyAccount(current, payload.account)
126
126
  const next = payload.next_cursor || cursor
127
- atomicJson(join(accountDir(current.id), 'SYNC.json'), { ...payload, account, synced_at: now().toISOString() })
127
+ const cachePath = join(accountDir(current.id), 'SYNC.json')
128
+ let cached = {}
129
+ try { cached = JSON.parse(readFileSync(cachePath, 'utf8')); verifyAccount(current, cached.account) }
130
+ catch (err) { if (err.code !== 'ENOENT') throw err }
131
+ const combined = { ...payload, account }
132
+ const pageCounts = {}
133
+ for (const key of ['submissions', 'events', 'contests']) {
134
+ pageCounts[key] = (payload[key] ?? []).length
135
+ const rows = new Map()
136
+ for (const row of [...(cached[key] ?? []), ...(payload[key] ?? [])]) rows.set(row.id, row)
137
+ combined[key] = [...rows.values()]
138
+ }
139
+ atomicJson(cachePath, { ...combined, synced_at: now().toISOString() })
128
140
  registry.accounts[String(current.id)] = { ...accountMeta, account, baseUrl: current.baseUrl, cursor: next, lastSyncAt: now().toISOString() }
129
141
  saveRegistry(registry)
130
- return { ...payload, account, cursor: next, cachedPath: join(accountDir(current.id), 'SYNC.json') }
142
+ return { ...combined, cursor: next, hasMore: payload.has_more === true, pageCounts, cachedPath: cachePath }
131
143
  },
132
144
  async replay({ contestId, limit = 50000 } = {}) {
133
145
  const current = requireActive()
@@ -0,0 +1,38 @@
1
+ import { constants, copyFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ const PERSONAL_FILES = ['PROGRESS.yaml', 'CURRICULUM.yaml', 'SCHEDULE.yaml', 'TARGET.yaml']
5
+
6
+ function copyMissing(from, to) {
7
+ try { copyFileSync(from, to, constants.COPYFILE_EXCL); return true }
8
+ catch (err) { if (err.code === 'EEXIST') return false; throw err }
9
+ }
10
+
11
+ export function migratePersonalWorkspace(dataDir, accountId) {
12
+ if (!accountId) return []
13
+ const id = String(accountId)
14
+ if (!/^[1-9]\d*$/.test(id)) throw new Error('个人文件迁移需要合法的正整数 VP 账号 id。')
15
+ const sourceDir = join(dataDir, 'vp-accounts', id)
16
+ const marker = join(sourceDir, 'PERSONAL_MIGRATION.json')
17
+ if (!existsSync(sourceDir) || existsSync(marker)) return []
18
+ const migrated = [], files = {}
19
+ for (const file of PERSONAL_FILES) {
20
+ const from = join(sourceDir, file), to = join(dataDir, file)
21
+ if (!existsSync(from)) { files[file] = 'source-missing'; continue }
22
+ if (copyMissing(from, to)) {
23
+ files[file] = 'filled-missing'
24
+ migrated.push(file)
25
+ } else if (readFileSync(from).equals(readFileSync(to))) {
26
+ files[file] = 'identical'
27
+ } else {
28
+ // File modification times cannot tell which version the learner intended to keep.
29
+ const backup = `${to}.from-vp-account-${id}`
30
+ if (copyMissing(from, backup)) migrated.push(`${file}(账号冲突副本已备份,主账本保留)`)
31
+ files[file] = 'conflict-preserved'
32
+ }
33
+ }
34
+ try {
35
+ writeFileSync(marker, JSON.stringify({ version: 1, accountId: id, completedAt: new Date().toISOString(), files }, null, 2) + '\n', { encoding: 'utf8', flag: 'wx' })
36
+ } catch (err) { if (err.code !== 'EEXIST') throw err }
37
+ return migrated
38
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "acmer-coach",
3
- "description": "小鲸教练插件:把「位置 + 判断」从小鲸的转述里拿回来。读 MAP/DEPENDS/PROGRESS,给唯一动作、判因、检测。热插拔,删掉即还原。",
4
- "version": "2.3.1",
3
+ "description": "小鲸教练插件:保存训练位置和真实证据,支持教练判断、持续教学与比赛备赛。读 MAP/DEPENDS/PROGRESS,提供训练、判因和检测工具。",
4
+ "version": "2.5.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/Miantiao1231/acmer-coach.git"
@@ -19,6 +19,12 @@
19
19
  "dependencies": {
20
20
  "yaml": "^2.9.0"
21
21
  },
22
+ "devDependencies": {
23
+ "@deepseek-ai/dsh-tools": "0.2.0-rc.2"
24
+ },
25
+ "scripts": {
26
+ "test": "node --test dev/curriculum-check.mjs dev/curriculum-evidence-check.mjs dev/strategy-check.mjs dev/catalog-check.mjs dev/coaching-contract-check.mjs dev/teaching-check.mjs dev/workspace-migration-check.mjs dev/vp-client-check.mjs dev/vp-sync-check.mjs"
27
+ },
22
28
  "dsh": {
23
29
  "engines": {
24
30
  "dsh": ">=0.1.1-rc.1"