acmer-coach 2.0.0 → 2.2.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/curriculum.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // 课程路线保存教学决定;进度和检测证据仍从 PROGRESS 读取。
2
- import { readFileSync, writeFileSync, renameSync, unlinkSync } from 'node:fs'
2
+ import { copyFileSync, readFileSync, writeFileSync, renameSync, unlinkSync } from 'node:fs'
3
3
  import { join } from 'node:path'
4
4
  import { randomUUID } from 'node:crypto'
5
5
  import { parse, stringify } from 'yaml'
@@ -17,13 +17,18 @@ function validDate(value) {
17
17
  const d = new Date(`${value}T12:00:00Z`)
18
18
  return Number.isFinite(d.getTime()) && d.toISOString().slice(0, 10) === value
19
19
  }
20
- export function createCurriculumStore({ dataDir, loadMap, loadProgress, loadSchedule = () => ({ days: {} }), now = () => new Date() }) {
20
+ export function createCurriculumStore({ dataDir, loadMap, loadProgress, loadSchedule = () => ({ days: {} }), loadStrategyEvidence = () => [], now = () => new Date() }) {
21
21
  const path = join(dataDir, 'CURRICULUM.yaml')
22
- const evidence = () => collectEvidence(loadProgress(), loadSchedule()).filter((entry) => loadMap().byId.has(entry.node))
22
+ const evidence = () => [
23
+ ...collectEvidence(loadProgress(), loadSchedule()).filter((entry) => loadMap().byId.has(entry.node)),
24
+ ...loadStrategyEvidence().filter((entry) => entry && typeof entry.id === 'string' && typeof entry.competency === 'string'),
25
+ ]
23
26
  function enter(doc, catalog) {
24
27
  doc.enteredAt = now().toISOString()
25
- doc.entryEvidenceRefs = catalog.filter((entry) => doc.phases[doc.current].nodes.includes(entry.node)).map((entry) => entry.id)
26
- doc.entryEvidenceEvents = catalog.filter((entry) => doc.phases[doc.current].nodes.includes(entry.node)).map((entry) => entry.eventId)
28
+ const phase = doc.phases[doc.current]
29
+ const inPhase = catalog.filter((entry) => phase.nodes.includes(entry.node) || phase.competencies.includes(entry.competency))
30
+ doc.entryEvidenceRefs = inPhase.map((entry) => entry.id)
31
+ doc.entryEvidenceEvents = inPhase.map((entry) => entry.eventId)
27
32
  }
28
33
  function budget(doc) {
29
34
  const remainingEstimatedHours = doc.status === 'complete' ? 0 : doc.phases.slice(doc.current).reduce((sum, phase) => sum + phase.estimatedHours, 0)
@@ -75,7 +80,12 @@ export function createCurriculumStore({ dataDir, loadMap, loadProgress, loadSche
75
80
  const temporary = `${path}.${randomUUID()}.tmp`
76
81
  try {
77
82
  writeFileSync(temporary, '# 课程路线;进度与检测证据仍以 PROGRESS.yaml 为准。\n' + stringify(doc, { lineWidth: 0 }), 'utf8')
78
- renameSync(temporary, path)
83
+ try { renameSync(temporary, path) } catch (err) {
84
+ if (!['EPERM', 'EEXIST'].includes(err.code)) throw err
85
+ try { unlinkSync(path) } catch (removeErr) { if (removeErr.code !== 'ENOENT') throw err }
86
+ copyFileSync(temporary, path)
87
+ unlinkSync(temporary)
88
+ }
79
89
  } finally {
80
90
  try { unlinkSync(temporary) } catch (err) { if (err.code !== 'ENOENT') throw err }
81
91
  }
@@ -84,7 +94,7 @@ export function createCurriculumStore({ dataDir, loadMap, loadProgress, loadSche
84
94
  const empty = {
85
95
  ok: true, reject: '', exists: false, revision: 0, status: 'none', goal: '', baseline: '',
86
96
  weeklyHours: 0, deadline: '', phaseId: '', phaseTitle: '', outcome: '', exitCriteria: '',
87
- focusNodes: [], focusUntil: '', focusExpired: false, requiredVerified: [], missingVerified: [],
97
+ focusNodes: [], phaseCompetencies: [], focusUntil: '', focusExpired: false, requiredVerified: [], missingVerified: [],
88
98
  pendingNodes: [], roadmap: [], history: [], updated: '',
89
99
  needsReview: false, planningWarnings: [], evidence: [], evidenceCount: 0, evidenceTruncated: false,
90
100
  missingPrerequisites: [], missingFreshEvidence: [],
@@ -103,6 +113,7 @@ export function createCurriculumStore({ dataDir, loadMap, loadProgress, loadSche
103
113
  baseline: doc.baseline, weeklyHours: doc.weeklyHours, deadline: doc.deadline,
104
114
  phaseId: phase.id, phaseTitle: phase.title, outcome: phase.outcome, exitCriteria: phase.exitCriteria,
105
115
  focusNodes: doc.focusNodes.length ? doc.focusNodes : phase.nodes,
116
+ phaseCompetencies: phase.competencies,
106
117
  focusUntil: doc.focusUntil, focusExpired: Boolean(doc.focusUntil && doc.focusUntil < dateOf(now)),
107
118
  requiredVerified: phase.requiredVerified,
108
119
  missingVerified: phase.requiredVerified.filter((n) => progress.nodes?.[n]?.status !== 'verified'),
@@ -112,9 +123,9 @@ export function createCurriculumStore({ dataDir, loadMap, loadProgress, loadSche
112
123
  history: doc.history.map(({ revision, action, reason, at, fromPhase, toPhase }) => ({ revision, action, reason, at, fromPhase, toPhase })),
113
124
  updated: doc.updated,
114
125
  needsReview: planningWarnings.length > 0, planningWarnings,
115
- evidence: catalog.filter((entry) => phase.nodes.includes(entry.node)).slice(-200),
116
- evidenceCount: catalog.filter((entry) => phase.nodes.includes(entry.node)).length,
117
- evidenceTruncated: catalog.filter((entry) => phase.nodes.includes(entry.node)).length > 200,
126
+ evidence: catalog.filter((entry) => phase.nodes.includes(entry.node) || phase.competencies.includes(entry.competency)).slice(-200),
127
+ evidenceCount: catalog.filter((entry) => phase.nodes.includes(entry.node) || phase.competencies.includes(entry.competency)).length,
128
+ evidenceTruncated: catalog.filter((entry) => phase.nodes.includes(entry.node) || phase.competencies.includes(entry.competency)).length > 200,
118
129
  missingPrerequisites,
119
130
  missingFreshEvidence: doc.version === 2 ? missingFreshEvidence(phase, catalog, doc.entryEvidenceRefs, dateOf(() => new Date(doc.enteredAt)), doc.entryEvidenceEvents) : [...phase.nodes],
120
131
  ...budget(doc),
@@ -180,7 +191,7 @@ export function createCurriculumStore({ dataDir, loadMap, loadProgress, loadSche
180
191
  throw new Error('当前诊断不能通过 revise 改成训练或删掉待诊断节点;先产生新记录,再 advance。')
181
192
  }
182
193
  doc.phases = phases
183
- const workKeys = ['kind', 'nodes', 'outcome', 'exitCriteria', 'basis', 'finding', 'evidenceRefs']
194
+ const workKeys = ['kind', 'nodes', 'competencies', 'outcome', 'exitCriteria', 'basis', 'finding', 'evidenceRefs']
184
195
  if (old.version === 1 || workKeys.some((key) => JSON.stringify(doc.phases[doc.current][key]) !== JSON.stringify(old.phases[old.current][key]))) enter(doc, catalog)
185
196
  }
186
197
  }
@@ -237,7 +248,8 @@ export function createCurriculumStore({ dataDir, loadMap, loadProgress, loadSche
237
248
  return [
238
249
  `- 长期目标:${state.goal};课程版本 ${state.revision}(${state.status})`,
239
250
  `- 当前阶段:${state.phaseTitle}(${state.phaseId});能力成果:${state.outcome}`,
240
- `- 主线节点:${state.focusNodes.join('、')}`,
251
+ `- 主线节点:${state.focusNodes.join('、') || '由比赛能力证据驱动'}`,
252
+ state.phaseCompetencies.length ? `- 阶段比赛能力:${state.phaseCompetencies.join('、')}` : '',
241
253
  `- 阶段验收:${state.exitCriteria}`,
242
254
  `- 阶段编排依据:${state.roadmap.find((phase) => phase.active)?.basis ?? ''}`,
243
255
  ...state.planningWarnings.map((warning) => `- 编排待核查:${warning}`),
@@ -255,7 +267,7 @@ export function createCurriculumStore({ dataDir, loadMap, loadProgress, loadSche
255
267
  const stringList = { type: 'array', items: { type: 'string' } }
256
268
  const phaseProperties = {
257
269
  id: { type: 'string', required: true }, title: { type: 'string', required: true },
258
- outcome: { type: 'string', required: true }, nodes: { ...stringList, required: true },
270
+ outcome: { type: 'string', required: true }, nodes: stringList, competencies: stringList,
259
271
  exitCriteria: { type: 'string', required: true }, requiredVerified: stringList,
260
272
  kind: { type: 'string', required: true, enum: ['diagnostic', 'training', 'provisional', 'legacy'], description: '诊断/有证据训练/远期待定;legacy只用于保留已完成历史。' },
261
273
  finding: { type: 'string', required: true, enum: ['gap', 'extension', 'unknown'] },
@@ -271,14 +283,14 @@ export function curriculumTool(store) {
271
283
  const required = (type) => ({ type, required: true })
272
284
  return {
273
285
  name: 'coach_curriculum',
274
- description: '按真实记录编排长期课程:assess取检测/判因/训练的证据id;read查看路线;create建立;revise按证据调整;advance验收后推进。训练阶段必须逐节点有真实短板或进阶起点,并按显式前置与必验阶段排序;无依据先diagnostic,远期可provisional但不能直接执行。阶段需目标贡献、优先级理由、时间估算。写操作带expectedRevision和reason;诊断/训练验收都需进入阶段后的新事件,不能复用旧记录。',
286
+ description: '按真实记录编排长期课程:assess取检测/判因/训练/VP的证据id;read查看路线;create建立;revise按证据调整;advance验收后推进。阶段可绑定地图节点,也可只绑定比赛能力(读题、决策、团队沟通);训练阶段必须有对应真实短板或进阶起点,并按显式前置与必验阶段排序;无依据先diagnostic,远期可provisional但不能直接执行。阶段需目标贡献、优先级理由、时间估算。写操作带expectedRevision和reason;诊断/训练验收都需进入阶段后的新事件,不能复用旧记录。',
275
287
  parameters: {
276
288
  action: { type: 'string', required: true, enum: ['assess', 'read', 'create', 'revise', 'advance'] },
277
289
  evidenceNodes: { ...stringList, description: 'assess可按地图节点过滤;最多返回200条,截断时缩小节点范围。' },
278
290
  expectedRevision: { type: 'integer', description: 'read 返回的版本;首次 create 为 0。' },
279
291
  goal: { type: 'string', description: '长期能力目标。' }, baseline: { type: 'string', description: '已核实的起点和待确认信息,不能猜测。' },
280
292
  weeklyHours: { type: 'number', description: '每周可用小时;0 表示未确认。' }, deadline: { type: 'string', description: '目标日期 YYYY-MM-DD,可留空。' },
281
- phases: { type: 'array', description: '按证据与前置排序;training所有节点必验;diagnostic收集新记录;provisional保留远期假设。', items: { type: 'object', additionalProperties: false, properties: phaseProperties } },
293
+ phases: { type: 'array', description: '按证据与前置排序;阶段至少绑定一个地图节点或比赛能力;training地图节点必验、能力阶段用新 VP/复盘证据验收;diagnostic收集新记录;provisional保留远期假设。', items: { type: 'object', additionalProperties: false, properties: phaseProperties } },
282
294
  focusNodes: { ...stringList, description: '当前阶段内的本周主线,空数组表示沿用阶段节点。' },
283
295
  focusUntil: { type: 'string', description: '本周主线截止日期 YYYY-MM-DD,可留空;到期先复盘。' },
284
296
  reason: { type: 'string', description: '建立/调整的依据;advance 时写验收证据及记录来源,至少 10 字。' },
@@ -289,16 +301,16 @@ export function curriculumTool(store) {
289
301
  status: required('string'), goal: required('string'), baseline: required('string'), weeklyHours: required('number'),
290
302
  deadline: required('string'), phaseId: required('string'), phaseTitle: required('string'), outcome: required('string'),
291
303
  exitCriteria: required('string'), focusUntil: required('string'), focusExpired: required('boolean'), updated: required('string'),
292
- focusNodes: { ...stringList, required: true }, requiredVerified: { ...stringList, required: true },
304
+ focusNodes: { ...stringList, required: true }, phaseCompetencies: { ...stringList, required: true }, requiredVerified: { ...stringList, required: true },
293
305
  missingVerified: { ...stringList, required: true }, pendingNodes: { ...stringList, required: true },
294
306
  needsReview: required('boolean'), planningWarnings: { ...stringList, required: true },
295
307
  missingPrerequisites: { ...stringList, required: true }, missingFreshEvidence: { ...stringList, required: true },
296
308
  evidenceCount: required('integer'), evidenceTruncated: required('boolean'),
297
309
  remainingEstimatedHours: required('number'), estimatedWeeks: required('number'), budgetWarnings: { ...stringList, required: true },
298
310
  evidence: { type: 'array', required: true, items: { type: 'object', additionalProperties: false, properties:
299
- Object.fromEntries(['id', 'eventId', 'source', 'node', 'date', 'signal', 'summary', 'fingerprint'].map((key) => [key, required('string')])) } },
311
+ { ...Object.fromEntries(['id', 'eventId', 'source', 'node', 'date', 'signal', 'summary', 'fingerprint'].map((key) => [key, required('string')])), competency: { type: 'string' } } } },
300
312
  roadmap: { type: 'array', required: true, items: { type: 'object', additionalProperties: false, properties: {
301
- ...phaseProperties, requiredVerified: { ...stringList, required: true }, position: required('integer'), active: required('boolean'),
313
+ ...phaseProperties, nodes: { ...stringList, required: true }, competencies: { ...stringList, required: true }, requiredVerified: { ...stringList, required: true }, position: required('integer'), active: required('boolean'),
302
314
  prerequisites: { ...stringList, required: true },
303
315
  } } },
304
316
  history: { type: 'array', required: true, items: { type: 'object', additionalProperties: false, properties: {
@@ -308,14 +320,14 @@ export function curriculumTool(store) {
308
320
  } },
309
321
  render: (args, state) => [{ type: 'text', text: !state.ok ? `❌ 课程未更新:${state.reject}`
310
322
  : args?.action === 'assess' ? ['【实际编排依据】',
311
- ...state.evidence.map((entry) => `${entry.id}\n ${entry.date} ${entry.node} · ${entry.signal} · ${entry.summary}`),
323
+ ...state.evidence.map((entry) => `${entry.id}\n ${entry.date} ${entry.node || entry.competency} · ${entry.signal} · ${entry.summary}`),
312
324
  state.evidenceCount ? `共${state.evidenceCount}条${state.evidenceTruncated ? ',仅返回最后200条;按evidenceNodes缩小范围' : ''}` : '没有可用记录。先建立诊断阶段,不凭rating或状态断言短板。',
313
325
  ...state.planningWarnings,
314
326
  ].join('\n')
315
327
  : !state.exists ? '尚无长期课程。先assess核查真实记录;缺依据先diagnostic,未来假设用provisional。'
316
328
  : [`【长期课程 · 版本 ${state.revision} · ${state.status}】${state.goal}`,
317
329
  ...state.roadmap.map((p) => `${p.active ? '▶ ' : ''}${p.position}. ${p.title} [${p.kind}]:${p.outcome}\n 依据:${p.basis}\n 排序:${p.priorityReason};预计${p.estimatedHours}小时`),
318
- `当前主线:${state.focusNodes.join('、')}`, `验收:${state.exitCriteria}`,
330
+ `当前主线:${state.focusNodes.join('、') || '能力证据驱动'}`, state.phaseCompetencies.length ? `阶段比赛能力:${state.phaseCompetencies.join('、')}` : '', `验收:${state.exitCriteria}`,
319
331
  state.missingVerified.length ? `必验缺口:${state.missingVerified.join('、')}` : '必验节点已有记录;仍需判断能力成果是否达到,不能自动推进。',
320
332
  state.pendingNodes.length ? `先完成当前单元:${state.pendingNodes.join('、')}` : '',
321
333
  state.focusExpired ? '本周主线到期,先复盘并修订。' : '',
package/lib/strategy.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // Goal-driven strategy layer for acmer-coach.
2
2
  // It stores contest targets, VP evidence and postmortems separately from
3
3
  // PROGRESS.yaml so the existing knowledge workflow keeps its meaning.
4
- import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
4
+ import { copyFileSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
5
5
  import { join } from 'node:path'
6
6
  import { parse, stringify } from 'yaml'
7
7
 
@@ -29,8 +29,22 @@ function readYaml(path, fallback) {
29
29
 
30
30
  function writeYaml(path, header, value) {
31
31
  const tmp = `${path}.${process.pid}.${Date.now()}.tmp`
32
- writeFileSync(tmp, header + stringify(value, { lineWidth: 0 }), 'utf8')
33
- renameSync(tmp, path)
32
+ let moved = false
33
+ try {
34
+ writeFileSync(tmp, header + stringify(value, { lineWidth: 0 }), 'utf8')
35
+ try { renameSync(tmp, path) } catch (err) {
36
+ if (!['EPERM', 'EEXIST'].includes(err.code)) throw err
37
+ // Windows sandbox may refuse rename over an existing file; keep the complete tmp file and replace by copy.
38
+ try { unlinkSync(path) } catch (removeErr) { if (removeErr.code !== 'ENOENT') throw err }
39
+ copyFileSync(tmp, path)
40
+ unlinkSync(tmp)
41
+ }
42
+ moved = true
43
+ } finally {
44
+ if (!moved) {
45
+ try { unlinkSync(tmp) } catch { /* best effort cleanup */ }
46
+ }
47
+ }
34
48
  }
35
49
 
36
50
  function normalizePriority(value) {
@@ -94,14 +108,46 @@ function normalizePostmortem(value) {
94
108
  export function createStrategyStore({ dataDir, competenciesPath, now = () => new Date() }) {
95
109
  const targetPath = join(dataDir, 'TARGET.yaml')
96
110
  const vpPath = join(dataDir, 'VP_EVENTS.yaml')
97
- const loadTarget = () => ({ ...emptyTarget(), ...readYaml(targetPath, emptyTarget) })
98
- const loadVp = () => ({ ...emptyVp(), ...readYaml(vpPath, emptyVp) })
111
+ const validateTarget = (doc) => {
112
+ const errors = []
113
+ if (doc.version !== 1) errors.push('version 必须是 1')
114
+ if (!Number.isInteger(doc.revision) || doc.revision < 0) errors.push('revision 必须是非负整数')
115
+ if (!Array.isArray(doc.priorities)) errors.push('priorities 必须是数组')
116
+ else if (doc.priorities.some((item) => !normalizePriority(item))) errors.push('priorities 含有无效的 id/weight')
117
+ if (!doc.constraints || typeof doc.constraints !== 'object' || Array.isArray(doc.constraints)) errors.push('constraints 必须是对象')
118
+ return errors
119
+ }
120
+ const validateVp = (doc) => {
121
+ const errors = []
122
+ if (doc.version !== 1) errors.push('version 必须是 1')
123
+ if (!Number.isInteger(doc.revision) || doc.revision < 0) errors.push('revision 必须是非负整数')
124
+ if (!Array.isArray(doc.events)) errors.push('events 必须是数组')
125
+ else if (doc.events.some((item) => !normalizeEvent(item))) errors.push('events 含有无效记录')
126
+ if (!Array.isArray(doc.postmortems)) errors.push('postmortems 必须是数组')
127
+ else if (doc.postmortems.some((item) => !normalizePostmortem(item))) errors.push('postmortems 含有无效记录')
128
+ return errors
129
+ }
130
+ const loadTarget = () => {
131
+ const doc = { ...emptyTarget(), ...readYaml(targetPath, emptyTarget) }
132
+ const errors = validateTarget(doc)
133
+ if (errors.length) throw new Error(`TARGET.yaml 数据无效:${errors.join(';')}`)
134
+ return doc
135
+ }
136
+ const loadVp = () => {
137
+ const doc = { ...emptyVp(), ...readYaml(vpPath, emptyVp) }
138
+ const errors = validateVp(doc)
139
+ if (errors.length) throw new Error(`VP_EVENTS.yaml 数据无效:${errors.join(';')}`)
140
+ return doc
141
+ }
99
142
  const saveTarget = (doc) => writeYaml(targetPath, '# TARGET.yaml — 比赛目标与能力优先级\n', doc)
100
143
  const saveVp = (doc) => writeYaml(vpPath, '# VP_EVENTS.yaml — VP 记录与赛后复盘\n', doc)
101
144
  const loadCompetencies = () => readYaml(competenciesPath, () => ({ version: 1, competencies: [] })).competencies ?? []
102
145
 
103
146
  function target(action = 'read', args = {}) {
104
- const old = loadTarget()
147
+ let old
148
+ try { old = loadTarget() } catch (err) {
149
+ return { ok: false, reject: err.message, ...emptyTarget() }
150
+ }
105
151
  if (action === 'read') return { ok: true, reject: '', ...old }
106
152
  if (!['set', 'clear'].includes(action)) return { ok: false, reject: 'action 只能是 read/set/clear。', ...old }
107
153
  if (args.expectedRevision !== old.revision) return { ok: false, reject: '目标版本已变化;先 read,再按当前 revision 更新。', ...old }
@@ -114,7 +160,9 @@ export function createStrategyStore({ dataDir, competenciesPath, now = () => new
114
160
  if (!clean(args.contest) || !isoDay(date) || !clean(args.result)) return { ok: false, reject: 'set 必须提供 contest、真实 YYYY-MM-DD 的 date 和 result。', ...old }
115
161
  const weeklyHours = Number(args.weeklyHours)
116
162
  if (!Number.isFinite(weeklyHours) || weeklyHours <= 0 || weeklyHours > 168) return { ok: false, reject: 'weeklyHours 必须在 0 和 168 之间。', ...old }
117
- const priorities = asArray(args.priorities).map(normalizePriority).filter(Boolean)
163
+ const rawPriorities = asArray(args.priorities)
164
+ const priorities = rawPriorities.map(normalizePriority).filter(Boolean)
165
+ if (rawPriorities.length !== priorities.length) return { ok: false, reject: 'priorities 中有无效项;每项都要提供正数 weight 和非空 id。', ...old }
118
166
  if (!priorities.length) return { ok: false, reject: '至少给一个能力优先级:id + weight。', ...old }
119
167
  const known = new Set(loadCompetencies().map((item) => item.id))
120
168
  const unknown = priorities.map((item) => item.id).filter((id) => !known.has(id))
@@ -132,13 +180,19 @@ export function createStrategyStore({ dataDir, competenciesPath, now = () => new
132
180
  }
133
181
 
134
182
  function vpImport(events = []) {
135
- const old = loadVp()
136
- const normalized = events.map(normalizeEvent).filter(Boolean)
183
+ let old
184
+ try { old = loadVp() } catch (err) {
185
+ return { ok: false, reject: err.message, imported: 0, total: 0, revision: 0, events: [], invalid: [] }
186
+ }
187
+ if (!Array.isArray(events) || !events.length) return { ok: false, reject: '至少导入一场含题目记录的 VP;空数组不会修改现有数据。', imported: 0, total: old.events.length, revision: old.revision, events: old.events, invalid: ['events 为空'] }
188
+ const normalized = events.map(normalizeEvent)
189
+ const invalid = normalized.map((event, index) => event ? '' : `第 ${index + 1} 条 VP 记录缺 eventId/contest/date/problems`).filter(Boolean)
190
+ if (invalid.length) return { ok: false, reject: 'VP 导入被拒绝:' + invalid.join(';'), imported: 0, total: old.events.length, revision: old.revision, events: old.events, invalid }
137
191
  const existing = new Map(old.events.map((event) => [event.eventId, event]))
138
192
  for (const event of normalized) existing.set(event.eventId, event)
139
193
  const next = { ...old, events: [...existing.values()].sort((a, b) => `${a.date}|${a.eventId}`.localeCompare(`${b.date}|${b.eventId}`)), revision: old.revision + 1, updated: now().toISOString() }
140
194
  saveVp(next)
141
- return { ok: true, reject: '', imported: normalized.length, total: next.events.length, revision: next.revision, events: next.events }
195
+ return { ok: true, reject: '', imported: normalized.length, total: next.events.length, revision: next.revision, events: next.events, invalid: [] }
142
196
  }
143
197
 
144
198
  function postmortem(value) {
@@ -155,8 +209,16 @@ export function createStrategyStore({ dataDir, competenciesPath, now = () => new
155
209
  }
156
210
 
157
211
  function focus() {
158
- const targetDoc = loadTarget()
159
- const vp = loadVp()
212
+ let targetDoc, vp
213
+ try {
214
+ targetDoc = loadTarget()
215
+ vp = loadVp()
216
+ } catch (err) {
217
+ return {
218
+ ok: false, reject: err.message, contest: '', deadline: '', focus: [], postponed: [],
219
+ evidenceEvents: 0, postmortems: 0,
220
+ }
221
+ }
160
222
  const catalog = loadCompetencies()
161
223
  if (!targetDoc.contest) return {
162
224
  ok: false, reject: '还没有比赛目标,先用 coach_target set。', contest: '', deadline: '',
@@ -164,9 +226,15 @@ export function createStrategyStore({ dataDir, competenciesPath, now = () => new
164
226
  }
165
227
  const postmortemHits = new Map()
166
228
  for (const record of vp.postmortems) for (const id of [...record.competencies, ...record.rootCauses]) postmortemHits.set(id, (postmortemHits.get(id) ?? 0) + 1)
229
+ const problemHits = new Map()
230
+ for (const event of vp.events) {
231
+ for (const id of new Set(event.problems.flatMap((problem) => problem.competencies))) {
232
+ problemHits.set(id, (problemHits.get(id) ?? 0) + 1)
233
+ }
234
+ }
167
235
  const focus = targetDoc.priorities.map((priority) => {
168
236
  const meta = catalog.find((item) => item.id === priority.id) ?? { id: priority.id, title: priority.id, contestUse: '暂无比赛能力卡片,先补充定义。', nodes: [] }
169
- const evidence = postmortemHits.get(priority.id) ?? 0
237
+ const evidence = (postmortemHits.get(priority.id) ?? 0) + (problemHits.get(priority.id) ?? 0)
170
238
  const urgency = Math.max(1, 30 - Math.floor((Date.parse(`${targetDoc.date}T12:00:00Z`) - Date.now()) / 86400000))
171
239
  const score = Math.round(priority.weight * (1 + evidence) * urgency / 10) / 10
172
240
  return { id: priority.id, title: meta.title ?? priority.id, score, evidenceCount: evidence, contestUse: meta.contestUse ?? '', nodes: meta.nodes ?? [], whyNow: evidence ? `最近复盘出现 ${evidence} 次相关问题;优先修复它。` : (priority.reason || '它是目标中的高权重能力,目前缺少 VP 证据。') }
@@ -181,6 +249,47 @@ export function createStrategyStore({ dataDir, competenciesPath, now = () => new
181
249
  return { ok: false, reject: `「${nodeOrId}」还没有范围卡片,不能凭空编造适用范围;先补充 COMPETENCIES.yaml。`, found: false, id: nodeOrId, title: nodeOrId, contestUse: '', nodes: [], appliesTo: [], extensions: [], notFor: [], commonFailures: [], transferProblems: [], exitEvidence: [] }
182
250
  }
183
251
 
252
+ function route(nodeOrId, { purpose = 'progress', courseBound = false } = {}) {
253
+ const node = clean(nodeOrId)
254
+ let targetDoc
255
+ try { targetDoc = loadTarget() } catch (err) { return { ok: false, reject: err.message, node, purpose, courseBound: Boolean(courseBound), targetAligned: false, competencyIds: [] } }
256
+ const base = { node, purpose, courseBound: Boolean(courseBound), targetAligned: true, competencyIds: [] }
257
+ if (!targetDoc.contest || purpose !== 'progress' || courseBound) return { ok: true, reject: '', ...base }
258
+ const catalog = loadCompetencies()
259
+ const priorityIds = new Set(targetDoc.priorities.map((item) => item.id))
260
+ const current = focus()
261
+ const currentIds = new Set((current.focus ?? []).map((item) => item.id))
262
+ const targetCards = catalog.filter((item) => priorityIds.has(item.id) && item.nodes?.includes(node))
263
+ const currentCards = targetCards.filter((item) => currentIds.has(item.id))
264
+ const mappedTarget = catalog.filter((item) => priorityIds.has(item.id) && item.nodes?.length)
265
+ if (mappedTarget.length && !currentCards.length) {
266
+ return { ok: false, reject: `「${node}」不服务当前比赛重点;当前重点是 ${current.focus.map((item) => item.id).join('、')}。如果这是课程主线,请带 curriculumRevision;如果是补漏/复习,请声明 purpose 和 routeReason。`, ...base, targetAligned: false, competencyIds: targetCards.map((item) => item.id) }
267
+ }
268
+ return { ok: true, reject: '', ...base, targetAligned: currentCards.length > 0, competencyIds: currentCards.map((item) => item.id) }
269
+ }
270
+
271
+ function evidence() {
272
+ try {
273
+ const vp = loadVp()
274
+ const result = []
275
+ const baselineStatuses = new Set(['AC', 'OK', 'ACCEPTED', 'SOLVED'])
276
+ for (const event of vp.events) for (const problem of event.problems) {
277
+ const signal = baselineStatuses.has(problem.status) ? 'baseline' : 'gap'
278
+ for (const competency of new Set(problem.competencies)) {
279
+ const id = `vp:${event.eventId}:${problem.problemId}:${competency}`
280
+ result.push({ id, eventId: event.eventId, source: 'vp', node: '', competency,
281
+ date: event.date, signal, summary: `${problem.problemId} ${problem.status} · ${competency}`, fingerprint: id })
282
+ }
283
+ }
284
+ for (const record of vp.postmortems) for (const competency of new Set(record.competencies)) {
285
+ const id = `postmortem:${record.eventId}:${competency}`
286
+ result.push({ id, eventId: record.eventId, source: 'postmortem', node: '', competency,
287
+ date: record.date, signal: 'gap', summary: `复盘指出 ${competency}`, fingerprint: id })
288
+ }
289
+ return { ok: true, reject: '', evidence: result }
290
+ } catch (err) { return { ok: false, reject: err.message, evidence: [] } }
291
+ }
292
+
184
293
  function context() {
185
294
  const t = loadTarget()
186
295
  if (!t.contest) return '- 比赛目标:尚未建立;先用 coach_target set,不要把 rating 当成长期目标。'
@@ -194,7 +303,7 @@ export function createStrategyStore({ dataDir, competenciesPath, now = () => new
194
303
  ].filter(Boolean).join('\n')
195
304
  }
196
305
 
197
- return { target, vpImport, postmortem, focus, scope, context, loadTarget, loadVp, loadCompetencies }
306
+ return { target, vpImport, postmortem, focus, scope, route, evidence, context, loadTarget, loadVp, loadCompetencies }
198
307
  }
199
308
 
200
309
  export const normalizeVpEvent = normalizeEvent
@@ -0,0 +1,148 @@
1
+ import { chmodSync, copyFileSync, mkdirSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ const DEFAULT_BASE_URL = 'https://xiaojing.run'
5
+
6
+ const cleanBaseUrl = (value) => {
7
+ const url = new URL(String(value || DEFAULT_BASE_URL))
8
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
9
+ throw new Error('VP baseUrl 必须是干净的 http/https 地址')
10
+ }
11
+ return url.toString().replace(/\/$/, '')
12
+ }
13
+
14
+ function safeAccount(value) {
15
+ if (!value || typeof value !== 'object' || !Number.isInteger(Number(value.id))) throw new Error('VP 返回了无效账号')
16
+ return { id: Number(value.id), handle: String(value.handle || ''), display_name: String(value.display_name || value.handle || '') }
17
+ }
18
+
19
+ function atomicJson(path, value, mode = 0o600) {
20
+ const tmp = `${path}.${process.pid}.${Date.now()}.tmp`
21
+ writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, 'utf8')
22
+ try { chmodSync(tmp, mode) } catch { /* Windows 没有 Unix mode,不影响原子写 */ }
23
+ try { renameSync(tmp, path) } catch (err) {
24
+ if (err.code !== 'EPERM' && err.code !== 'EEXIST') throw err
25
+ try { unlinkSync(path) } catch (removeErr) { if (removeErr.code !== 'ENOENT') throw err }
26
+ copyFileSync(tmp, path)
27
+ unlinkSync(tmp)
28
+ }
29
+ }
30
+
31
+ export function createVpClient({ dataDir, fetchImpl = globalThis.fetch, now = () => new Date() } = {}) {
32
+ if (typeof fetchImpl !== 'function') throw new Error('VP 客户端需要 fetch')
33
+ const root = join(dataDir, 'vp-accounts')
34
+ const registryPath = join(dataDir, 'VP_ACCOUNTS.json')
35
+
36
+ const loadRegistry = () => {
37
+ try {
38
+ const doc = JSON.parse(readFileSync(registryPath, 'utf8'))
39
+ return doc && typeof doc === 'object' ? doc : { activeAccountId: '', accounts: {} }
40
+ } catch (err) {
41
+ if (err.code === 'ENOENT') return { version: 1, activeAccountId: '', accounts: {} }
42
+ throw err
43
+ }
44
+ }
45
+ const saveRegistry = (value) => atomicJson(registryPath, { version: 1, ...value })
46
+ const accountDir = (id) => join(root, String(id))
47
+ const tokenPath = (id) => join(accountDir(id), 'TOKEN.json')
48
+ const loadToken = (id) => JSON.parse(readFileSync(tokenPath(id), 'utf8')).access_token
49
+ const active = () => {
50
+ const registry = loadRegistry()
51
+ const id = Number(registry.activeAccountId || 0)
52
+ if (!id) return null
53
+ const meta = registry.accounts?.[String(id)]
54
+ if (!meta) return null
55
+ return { id, account: safeAccount(meta.account), baseUrl: cleanBaseUrl(meta.baseUrl || DEFAULT_BASE_URL) }
56
+ }
57
+ const request = async (baseUrl, path, { token, method = 'GET', body } = {}) => {
58
+ const response = await fetchImpl(`${cleanBaseUrl(baseUrl)}${path}`, {
59
+ method, headers: { accept: 'application/json', ...(body ? { 'content-type': 'application/json' } : {}), ...(token ? { authorization: `Bearer ${token}` } : {}) },
60
+ body: body ? JSON.stringify(body) : undefined,
61
+ })
62
+ let payload
63
+ try { payload = await response.json() } catch { payload = { detail: await response.text() } }
64
+ if (!response.ok) throw new Error(`VP ${response.status}: ${payload?.detail || payload?.message || '请求失败'}`)
65
+ return payload
66
+ }
67
+ const requireActive = () => {
68
+ const current = active()
69
+ if (!current) throw new Error('还没有连接 VP;先调用 coach_vp_connect start/finish')
70
+ return { ...current, token: loadToken(current.id) }
71
+ }
72
+ const verifyAccount = (current, value) => {
73
+ const returned = safeAccount(value)
74
+ if (returned.id !== current.id) throw new Error(`VP account mismatch: 当前是 ${current.id},接口返回 ${returned.id}`)
75
+ return returned
76
+ }
77
+
78
+ return {
79
+ async start({ baseUrl = DEFAULT_BASE_URL } = {}) {
80
+ const clean = cleanBaseUrl(baseUrl)
81
+ const result = await request(clean, '/api/coach/auth/start', { method: 'POST', body: {
82
+ redirect_uri: 'http://127.0.0.1:8765/coach/callback',
83
+ scopes: ['profile', 'contests', 'submissions', 'replay'],
84
+ } })
85
+ return { ok: true, requestId: result.request_id, authorizeUrl: result.authorize_url, expiresAt: result.expires_at, baseUrl: clean }
86
+ },
87
+ async finish({ code, baseUrl = DEFAULT_BASE_URL } = {}) {
88
+ const clean = cleanBaseUrl(baseUrl)
89
+ const result = await request(clean, '/api/coach/auth/exchange', { method: 'POST', body: { code: String(code || '') } })
90
+ const account = safeAccount(result.user)
91
+ const dir = accountDir(account.id)
92
+ mkdirSync(dir, { recursive: true })
93
+ atomicJson(tokenPath(account.id), { access_token: String(result.access_token), saved_at: now().toISOString() })
94
+ const registry = loadRegistry()
95
+ registry.accounts = registry.accounts || {}
96
+ registry.accounts[String(account.id)] = { account, baseUrl: clean, lastSyncAt: registry.accounts[String(account.id)]?.lastSyncAt || '' }
97
+ registry.activeAccountId = account.id
98
+ saveRegistry(registry)
99
+ return { ok: true, account, activeAccountId: account.id }
100
+ },
101
+ async status() {
102
+ const current = active()
103
+ if (!current) return { connected: false, account: null, activeAccountId: null }
104
+ try {
105
+ const remote = await request(current.baseUrl, '/api/coach/me', { token: loadToken(current.id) })
106
+ const account = verifyAccount(current, remote.user)
107
+ return { connected: true, account, activeAccountId: current.id, baseUrl: current.baseUrl, readOnly: remote.read_only !== false }
108
+ } catch (err) {
109
+ return { connected: false, account: current.account, activeAccountId: current.id, error: err.message }
110
+ }
111
+ },
112
+ async sync({ limit = 200, includeProfile = true } = {}) {
113
+ const current = requireActive()
114
+ const registry = loadRegistry()
115
+ const accountMeta = registry.accounts[String(current.id)] || {}
116
+ const cursor = accountMeta.cursor || { submission_id: 0, event_id: 0 }
117
+ const query = new URLSearchParams({ submission_cursor: String(cursor.submission_id || 0), event_cursor: String(cursor.event_id || 0), limit: String(Math.max(1, Math.min(500, Number(limit) || 200))), include_profile: includeProfile ? '1' : '0' })
118
+ const payload = await request(current.baseUrl, `/api/coach/sync?${query}`, { token: current.token })
119
+ const account = verifyAccount(current, payload.account)
120
+ const next = payload.next_cursor || cursor
121
+ atomicJson(join(accountDir(current.id), 'SYNC.json'), { ...payload, account, synced_at: now().toISOString() })
122
+ registry.accounts[String(current.id)] = { ...accountMeta, account, baseUrl: current.baseUrl, cursor: next, lastSyncAt: now().toISOString() }
123
+ saveRegistry(registry)
124
+ return { ...payload, account, cursor: next, cachedPath: join(accountDir(current.id), 'SYNC.json') }
125
+ },
126
+ async replay({ contestId, limit = 50000 } = {}) {
127
+ const current = requireActive()
128
+ const payload = await request(current.baseUrl, `/api/coach/replay/${encodeURIComponent(String(contestId))}?limit=${Math.max(1, Math.min(50000, Number(limit) || 50000))}`, { token: current.token })
129
+ const account = verifyAccount(current, payload.account)
130
+ const result = { ...payload, account, cachedPath: join(accountDir(current.id), `REPLAY-${String(contestId)}.json`) }
131
+ atomicJson(result.cachedPath, { ...payload, account, cached_at: now().toISOString() })
132
+ return result
133
+ },
134
+ disconnect() {
135
+ const current = active()
136
+ if (!current) return { ok: true, disconnected: false }
137
+ try { rmSync(tokenPath(current.id), { force: true }) } catch { /* best effort */ }
138
+ const registry = loadRegistry()
139
+ registry.activeAccountId = ''
140
+ saveRegistry(registry)
141
+ return { ok: true, disconnected: true, account: current.account, cached: true }
142
+ },
143
+ localStatus() {
144
+ const current = active()
145
+ return current ? { connected: true, account: current.account, activeAccountId: current.id, baseUrl: current.baseUrl } : { connected: false, account: null, activeAccountId: null }
146
+ },
147
+ }
148
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "acmer-coach",
3
3
  "description": "小鲸教练插件:把「位置 + 判断」从小鲸的转述里拿回来。读 MAP/DEPENDS/PROGRESS,给唯一动作、判因、检测。热插拔,删掉即还原。",
4
- "version": "2.0.0",
4
+ "version": "2.2.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/Miantiao1231/acmer-coach.git"
package/verify-shape.mjs CHANGED
@@ -169,7 +169,15 @@ 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'] }] }] }],
178
+ ['coach_vp_connect', { action: 'status' }],
179
+ ['coach_vp_sync', {}],
180
+ ['coach_vp_replay', { contestId: 1 }],
173
181
  ['coach_postmortem', { eventId: 'shape-vp', competencies: ['contest-reading'], rootCauses: ['测试复盘'], nextActions: ['测试动作'] }],
174
182
  ['coach_focus', {}],
175
183
  ['coach_scope', { id: 'contest-reading' }],
@@ -298,6 +306,11 @@ for (const [name, args, preset] of cases) {
298
306
  bad++
299
307
  continue
300
308
  }
309
+ if (name === 'coach_assign#target-gate' && out.accepted !== false) {
310
+ console.log(` FAIL ${name} → 目标硬闸没有拒绝无关 progress 节点`)
311
+ bad++
312
+ continue
313
+ }
301
314
  const errs = []
302
315
  validate(tool.output.schema, out, 'out', errs)
303
316
  let renderNote = ''