acmer-coach 2.3.0 → 2.4.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
@@ -4,7 +4,7 @@ import { join } from 'node:path'
4
4
  import { randomUUID } from 'node:crypto'
5
5
  import { parse, stringify } from 'yaml'
6
6
  import { collectEvidence } from './curriculum-evidence.js'
7
- import { normalizePhases, validatePlan, knownNodes, prerequisitesOf, missingFreshEvidence } from './curriculum-planning.js'
7
+ import { normalizePhases, validatePlan, knownNodes, prerequisitesOf, missingFreshEvidence, evaluateExitChecks } from './curriculum-planning.js'
8
8
 
9
9
  const clean = (value) => String(value ?? '').trim()
10
10
  const dateOf = (now) => {
@@ -51,7 +51,7 @@ export function createCurriculumStore({ dataDir, loadMap, loadProgress, loadSche
51
51
  throw err
52
52
  }
53
53
  const doc = parse(raw)
54
- if (!doc || ![1, 2].includes(doc.version) || !Number.isInteger(doc.revision) || doc.revision < 1 ||
54
+ if (!doc || ![1, 2, 3].includes(doc.version) || !Number.isInteger(doc.revision) || doc.revision < 1 ||
55
55
  !['active', 'complete'].includes(doc.status) || typeof doc.goal !== 'string' || !clean(doc.goal) ||
56
56
  typeof doc.baseline !== 'string' || typeof doc.deadline !== 'string' ||
57
57
  typeof doc.focusUntil !== 'string' || typeof doc.updated !== 'string' ||
@@ -63,8 +63,8 @@ export function createCurriculumStore({ dataDir, loadMap, loadProgress, loadSche
63
63
  ['reason', 'at', 'fromPhase', 'toPhase'].some((key) => typeof entry[key] !== 'string'))) {
64
64
  throw new Error('CURRICULUM.yaml 损坏或版本不支持,请检查文件;不会当作没有计划覆盖它。')
65
65
  }
66
- doc.phases = normalizePhases(doc.phases, loadMap().byId, { legacy: doc.version === 1, completed: doc.phases.slice(0, doc.current) })
67
- if (doc.version === 2 && (typeof doc.enteredAt !== 'string' || !Number.isFinite(Date.parse(doc.enteredAt)) ||
66
+ doc.phases = normalizePhases(doc.phases, loadMap().byId, { legacy: doc.version === 1, allowMissingChecks: doc.version < 3, completed: doc.phases.slice(0, doc.current) })
67
+ if (doc.version >= 2 && (typeof doc.enteredAt !== 'string' || !Number.isFinite(Date.parse(doc.enteredAt)) ||
68
68
  !Array.isArray(doc.entryEvidenceRefs) || doc.entryEvidenceRefs.some((ref) => typeof ref !== 'string') ||
69
69
  !Array.isArray(doc.entryEvidenceEvents) || doc.entryEvidenceEvents.some((ref) => typeof ref !== 'string'))) {
70
70
  throw new Error('课程的阶段进入记录无效;不能猜测哪些记录是在本阶段产生的。')
@@ -97,7 +97,7 @@ export function createCurriculumStore({ dataDir, loadMap, loadProgress, loadSche
97
97
  focusNodes: [], phaseCompetencies: [], focusUntil: '', focusExpired: false, requiredVerified: [], missingVerified: [],
98
98
  pendingNodes: [], roadmap: [], history: [], updated: '',
99
99
  needsReview: false, planningWarnings: [], evidence: [], evidenceCount: 0, evidenceTruncated: false,
100
- missingPrerequisites: [], missingFreshEvidence: [],
100
+ missingPrerequisites: [], missingFreshEvidence: [], exitChecks: [], exitCheckResults: [], evidencePrecisionWarnings: [],
101
101
  remainingEstimatedHours: 0, estimatedWeeks: 0, budgetWarnings: [],
102
102
  }
103
103
  if (!doc) return empty
@@ -105,13 +105,17 @@ export function createCurriculumStore({ dataDir, loadMap, loadProgress, loadSche
105
105
  const progress = loadProgress()
106
106
  const catalog = evidence()
107
107
  const planningWarnings = []
108
- try { validatePlan(doc.phases, { byId: loadMap().byId, progress, catalog, current: doc.current }) }
108
+ try { if (doc.status === 'active') validatePlan(doc.phases, { byId: loadMap().byId, progress, catalog, current: doc.current }) }
109
109
  catch (err) { planningWarnings.push(err.message) }
110
110
  const missingPrerequisites = phase.prerequisites.filter((node) => !knownNodes(progress).has(node))
111
+ const exitCheckResults = doc.version >= 2 ? evaluateExitChecks(phase, catalog, doc.entryEvidenceRefs, dateOf(() => new Date(doc.enteredAt)), doc.entryEvidenceEvents, dateOf(now), doc.enteredAt, now().toISOString()) : []
111
112
  return {
112
113
  ...empty, exists: true, revision: doc.revision, status: doc.status, goal: doc.goal,
113
114
  baseline: doc.baseline, weeklyHours: doc.weeklyHours, deadline: doc.deadline,
114
115
  phaseId: phase.id, phaseTitle: phase.title, outcome: phase.outcome, exitCriteria: phase.exitCriteria,
116
+ exitChecks: phase.exitChecks, exitCheckResults,
117
+ evidencePrecisionWarnings: catalog.some((entry) => (phase.nodes.includes(entry.node) || phase.competencies.includes(entry.competency)) && entry.occurredAt === undefined)
118
+ ? ['部分证据只有日期:进入快照排除了既有旧事件,但同日后导入的事件不能核验发生在阶段进入之前还是之后。'] : [],
115
119
  focusNodes: doc.focusNodes.length ? doc.focusNodes : phase.nodes,
116
120
  phaseCompetencies: phase.competencies,
117
121
  focusUntil: doc.focusUntil, focusExpired: Boolean(doc.focusUntil && doc.focusUntil < dateOf(now)),
@@ -127,7 +131,8 @@ export function createCurriculumStore({ dataDir, loadMap, loadProgress, loadSche
127
131
  evidenceCount: catalog.filter((entry) => phase.nodes.includes(entry.node) || phase.competencies.includes(entry.competency)).length,
128
132
  evidenceTruncated: catalog.filter((entry) => phase.nodes.includes(entry.node) || phase.competencies.includes(entry.competency)).length > 200,
129
133
  missingPrerequisites,
130
- missingFreshEvidence: doc.version === 2 ? missingFreshEvidence(phase, catalog, doc.entryEvidenceRefs, dateOf(() => new Date(doc.enteredAt)), doc.entryEvidenceEvents) : [...phase.nodes],
134
+ missingFreshEvidence: phase.kind === 'training' && phase.exitChecks.length ? exitCheckResults.filter((check) => !check.passed).map((check) => check.target)
135
+ : doc.version >= 2 ? missingFreshEvidence(phase, catalog, doc.entryEvidenceRefs, dateOf(() => new Date(doc.enteredAt)), doc.entryEvidenceEvents, dateOf(now), doc.enteredAt, now().toISOString()) : [...phase.nodes],
131
136
  ...budget(doc),
132
137
  }
133
138
  }
@@ -139,7 +144,9 @@ export function createCurriculumStore({ dataDir, loadMap, loadProgress, loadSche
139
144
  if (action === 'assess') {
140
145
  const requested = args.evidenceNodes
141
146
  if (requested !== undefined && (!Array.isArray(requested) || requested.some((node) => typeof node !== 'string' || !loadMap().byId.has(node)))) throw new Error('evidenceNodes 必须是地图节点数组。')
142
- const catalog = evidence().filter((entry) => !requested?.length || requested.includes(entry.node))
147
+ const events = args.evidenceEventIds
148
+ if (events !== undefined && (!Array.isArray(events) || events.some((event) => typeof event !== 'string' || !event.trim()))) throw new Error('evidenceEventIds 必须是非空场次 eventId 字符串组成的数组。')
149
+ const catalog = evidence().filter((entry) => (!requested?.length || requested.includes(entry.node)) && (!events?.length || events.includes(entry.eventId)))
143
150
  return { ...snapshot(old), evidence: catalog.slice(-200), evidenceCount: catalog.length, evidenceTruncated: catalog.length > 200 }
144
151
  }
145
152
  if (!['create', 'revise', 'advance'].includes(action)) throw new Error('action 只能是 assess/read/create/revise/advance。')
@@ -152,7 +159,7 @@ export function createCurriculumStore({ dataDir, loadMap, loadProgress, loadSche
152
159
  if (action === 'create') {
153
160
  if (old?.status === 'active') throw new Error('已有有效路线,请 revise;不能用 create 悄悄替换正在执行的课程。')
154
161
  doc = {
155
- version: 2, goal: clean(args.goal), baseline: clean(args.baseline),
162
+ version: 3, goal: clean(args.goal), baseline: clean(args.baseline),
156
163
  weeklyHours: args.weeklyHours ?? 0, deadline: clean(args.deadline),
157
164
  phases: normalizePhases(args.phases, byId), current: 0, status: 'active',
158
165
  focusNodes: [], focusUntil: '', history: old?.history ?? [],
@@ -164,6 +171,7 @@ export function createCurriculumStore({ dataDir, loadMap, loadProgress, loadSche
164
171
  if (action === 'advance') {
165
172
  const state = snapshot(old)
166
173
  if (state.needsReview) throw new Error(state.planningWarnings.join(';'))
174
+ if (state.exitCheckResults.some((check) => !check.passed)) throw new Error(`阶段结构化验收未通过:${state.exitCheckResults.filter((check) => !check.passed).map((check) => `${check.target}(${check.missing.join(',')})`).join(';')}。只有符合条件的新成功事实计数,不能复用旧事件。`)
167
175
  if (state.missingFreshEvidence.length) throw new Error(`本阶段尚缺新${old.phases[old.current].kind === 'diagnostic' ? '诊断' : '独立完成或过卷'}记录:${state.missingFreshEvidence.join('、')}。不能复用进入阶段之前的记录。`)
168
176
  if (state.missingVerified.length) throw new Error(`阶段还缺检测证据:${state.missingVerified.join('、')}。不会按日历自动推进。`)
169
177
  if (state.pendingNodes.length) throw new Error('当前阶段还有押着的动作,先完成或撤销,再决定推进。')
@@ -191,7 +199,7 @@ export function createCurriculumStore({ dataDir, loadMap, loadProgress, loadSche
191
199
  throw new Error('当前诊断不能通过 revise 改成训练或删掉待诊断节点;先产生新记录,再 advance。')
192
200
  }
193
201
  doc.phases = phases
194
- const workKeys = ['kind', 'nodes', 'competencies', 'outcome', 'exitCriteria', 'basis', 'finding', 'evidenceRefs']
202
+ const workKeys = ['kind', 'nodes', 'competencies', 'outcome', 'exitCriteria', 'exitChecks', 'basis', 'finding', 'evidenceRefs']
195
203
  if (old.version === 1 || workKeys.some((key) => JSON.stringify(doc.phases[doc.current][key]) !== JSON.stringify(old.phases[old.current][key]))) enter(doc, catalog)
196
204
  }
197
205
  }
@@ -206,7 +214,7 @@ export function createCurriculumStore({ dataDir, loadMap, loadProgress, loadSche
206
214
  if (new Set(doc.focusNodes).size !== doc.focusNodes.length || doc.focusNodes.some((n) => !doc.phases[doc.current].nodes.includes(n))) throw new Error('本周主线必须属于当前阶段,不能直接跳到未来阶段。')
207
215
  if (!validDate(doc.focusUntil)) throw new Error('focusUntil 必须为空或真实的 YYYY-MM-DD 日期。')
208
216
  validatePlan(doc.phases, { byId, progress: loadProgress(), catalog, current: doc.current })
209
- doc.version = 2
217
+ doc.version = 3
210
218
  doc.revision = (old?.revision ?? 0) + 1
211
219
  doc.updated = now().toISOString()
212
220
  const previous = old ? { ...old } : null
@@ -251,6 +259,8 @@ export function createCurriculumStore({ dataDir, loadMap, loadProgress, loadSche
251
259
  `- 主线节点:${state.focusNodes.join('、') || '由比赛能力证据驱动'}`,
252
260
  state.phaseCompetencies.length ? `- 阶段比赛能力:${state.phaseCompetencies.join('、')}` : '',
253
261
  `- 阶段验收:${state.exitCriteria}`,
262
+ ...state.exitCheckResults.map((check) => `- 结构化验收 ${check.target}:${check.passed ? '已满足' : check.missing.join(';')};只证明这些条件下的表现,不代表永久掌握。`),
263
+ ...state.evidencePrecisionWarnings.map((warning) => `- 证据精度:${warning}`),
254
264
  `- 阶段编排依据:${state.roadmap.find((phase) => phase.active)?.basis ?? ''}`,
255
265
  ...state.planningWarnings.map((warning) => `- 编排待核查:${warning}`),
256
266
  ...state.budgetWarnings.map((warning) => `- 时间预算:${warning}`),
@@ -265,10 +275,19 @@ export function createCurriculumStore({ dataDir, loadMap, loadProgress, loadSche
265
275
  }
266
276
 
267
277
  const stringList = { type: 'array', items: { type: 'string' } }
278
+ const exitCheckProperties = {
279
+ node: { type: 'string' }, competency: { type: 'string' },
280
+ minEvents: { type: 'integer', required: true, description: '不同新成功事件的最少次数;进入阶段前的事件不计。' },
281
+ minDistinctDays: { type: 'integer', description: '至少跨几个不同日期,1–minEvents;2表示跨日复测。' },
282
+ independent: { type: 'boolean', description: '必须有明确相符的独立性事实,不从说明推测。' },
283
+ maxMinutes: { type: 'number', description: '每个合格事件的最大用时,必须有真实数值。' },
284
+ metric: { type: 'string', description: '待检查的事实指标名,需与maxValue一起填。' }, maxValue: { type: 'number' },
285
+ }
286
+ const exitChecksSchema = { type: 'array', items: { type: 'object', additionalProperties: false, properties: exitCheckProperties } }
268
287
  const phaseProperties = {
269
288
  id: { type: 'string', required: true }, title: { type: 'string', required: true },
270
289
  outcome: { type: 'string', required: true }, nodes: stringList, competencies: stringList,
271
- exitCriteria: { type: 'string', required: true }, requiredVerified: stringList,
290
+ exitCriteria: { type: 'string', required: true }, exitChecks: { ...exitChecksSchema, description: 'training必须覆盖每个节点/能力的结构化验收;文字验收不能代替次数、跨日、独立性、用时或事实指标。' }, requiredVerified: stringList,
272
291
  kind: { type: 'string', required: true, enum: ['diagnostic', 'training', 'provisional', 'legacy'], description: '诊断/有证据训练/远期待定;legacy只用于保留已完成历史。' },
273
292
  finding: { type: 'string', required: true, enum: ['gap', 'extension', 'unknown'] },
274
293
  basis: { type: 'string', required: true, description: '具体观察或待确认问题,至少10字。' },
@@ -283,10 +302,11 @@ export function curriculumTool(store) {
283
302
  const required = (type) => ({ type, required: true })
284
303
  return {
285
304
  name: 'coach_curriculum',
286
- description: '按真实记录编排长期课程:assess取检测/判因/训练/VP的证据id;read查看路线;create建立;revise按证据调整;advance验收后推进。阶段可绑定地图节点,也可只绑定比赛能力(读题、决策、团队沟通);训练阶段必须有对应真实短板或进阶起点,并按显式前置与必验阶段排序;无依据先diagnostic,远期可provisional但不能直接执行。阶段需目标贡献、优先级理由、时间估算。写操作带expectedRevision和reason;诊断/训练验收都需进入阶段后的新事件,不能复用旧记录。',
305
+ description: '按真实记录编排长期课程:assess取检测/判因/训练/VP的证据id,可用evidenceEventIds精确查询某场历史VP,避免200条返回截断;read查看路线;create建立;revise按证据调整;advance验收后推进。阶段可绑定地图节点,也可只绑定比赛能力(读题、决策、团队沟通);训练阶段必须有对应真实短板或进阶起点,并按显式前置与必验阶段排序;无依据先diagnostic,远期可provisional但不能直接执行。阶段需目标贡献、优先级理由、时间估算。写操作带expectedRevision和reason;诊断/训练验收都需进入阶段后的新事件,不能复用旧记录。',
287
306
  parameters: {
288
307
  action: { type: 'string', required: true, enum: ['assess', 'read', 'create', 'revise', 'advance'] },
289
- evidenceNodes: { ...stringList, description: 'assess可按地图节点过滤;最多返回200条,截断时缩小节点范围。' },
308
+ evidenceNodes: { ...stringList, description: 'assess可按地图节点过滤;最多返回200条,截断时缩小节点或场次范围。' },
309
+ evidenceEventIds: { ...stringList, description: 'assess按真实场次eventId精确过滤,先筛选再取最后200条;可读取较早VP的未标注能力事实。与evidenceNodes同时填写时取交集;空数组不限场次,不影响阶段全量验收。' },
290
310
  expectedRevision: { type: 'integer', description: 'read 返回的版本;首次 create 为 0。' },
291
311
  goal: { type: 'string', description: '长期能力目标。' }, baseline: { type: 'string', description: '已核实的起点和待确认信息,不能猜测。' },
292
312
  weeklyHours: { type: 'number', description: '每周可用小时;0 表示未确认。' }, deadline: { type: 'string', description: '目标日期 YYYY-MM-DD,可留空。' },
@@ -305,10 +325,16 @@ export function curriculumTool(store) {
305
325
  missingVerified: { ...stringList, required: true }, pendingNodes: { ...stringList, required: true },
306
326
  needsReview: required('boolean'), planningWarnings: { ...stringList, required: true },
307
327
  missingPrerequisites: { ...stringList, required: true }, missingFreshEvidence: { ...stringList, required: true },
328
+ evidencePrecisionWarnings: { ...stringList, required: true },
329
+ exitChecks: { ...exitChecksSchema, required: true },
330
+ exitCheckResults: { type: 'array', required: true, items: { type: 'object', additionalProperties: false, properties: {
331
+ ...exitCheckProperties, target: required('string'), matchedEvents: required('integer'), distinctDays: required('integer'), passed: required('boolean'), missing: { ...stringList, required: true },
332
+ } } },
308
333
  evidenceCount: required('integer'), evidenceTruncated: required('boolean'),
309
334
  remainingEstimatedHours: required('number'), estimatedWeeks: required('number'), budgetWarnings: { ...stringList, required: true },
310
335
  evidence: { type: 'array', required: true, items: { type: 'object', additionalProperties: false, properties:
311
- { ...Object.fromEntries(['id', 'eventId', 'source', 'node', 'date', 'signal', 'summary', 'fingerprint'].map((key) => [key, required('string')])), competency: { type: 'string' } } } },
336
+ { ...Object.fromEntries(['id', 'eventId', 'source', 'node', 'date', 'signal', 'summary', 'fingerprint'].map((key) => [key, required('string')])), competency: { type: 'string' },
337
+ independent: { type: 'boolean' }, minutes: { type: 'number' }, metric: { type: 'string' }, value: { type: 'number' }, teamMode: { type: 'string' }, transfer: { type: 'boolean' }, occurredAt: { type: 'string' } } } },
312
338
  roadmap: { type: 'array', required: true, items: { type: 'object', additionalProperties: false, properties: {
313
339
  ...phaseProperties, nodes: { ...stringList, required: true }, competencies: { ...stringList, required: true }, requiredVerified: { ...stringList, required: true }, position: required('integer'), active: required('boolean'),
314
340
  prerequisites: { ...stringList, required: true },
@@ -321,13 +347,15 @@ export function curriculumTool(store) {
321
347
  render: (args, state) => [{ type: 'text', text: !state.ok ? `❌ 课程未更新:${state.reject}`
322
348
  : args?.action === 'assess' ? ['【实际编排依据】',
323
349
  ...state.evidence.map((entry) => `${entry.id}\n ${entry.date} ${entry.node || entry.competency} · ${entry.signal} · ${entry.summary}`),
324
- state.evidenceCount ? `共${state.evidenceCount}条${state.evidenceTruncated ? ',仅返回最后200条;按evidenceNodes缩小范围' : ''}` : '没有可用记录。先建立诊断阶段,不凭rating或状态断言短板。',
350
+ state.evidenceCount ? `共${state.evidenceCount}条${state.evidenceTruncated ? ',仅返回最后200条;按evidenceNodes或evidenceEventIds缩小范围' : ''}` : '没有可用记录。先建立诊断阶段,不凭rating或状态断言短板。',
325
351
  ...state.planningWarnings,
326
352
  ].join('\n')
327
353
  : !state.exists ? '尚无长期课程。先assess核查真实记录;缺依据先diagnostic,未来假设用provisional。'
328
354
  : [`【长期课程 · 版本 ${state.revision} · ${state.status}】${state.goal}`,
329
355
  ...state.roadmap.map((p) => `${p.active ? '▶ ' : ''}${p.position}. ${p.title} [${p.kind}]:${p.outcome}\n 依据:${p.basis}\n 排序:${p.priorityReason};预计${p.estimatedHours}小时`),
330
356
  `当前主线:${state.focusNodes.join('、') || '能力证据驱动'}`, state.phaseCompetencies.length ? `阶段比赛能力:${state.phaseCompetencies.join('、')}` : '', `验收:${state.exitCriteria}`,
357
+ ...state.exitCheckResults.map((check) => `结构化验收 ${check.target}:${check.passed ? `合格事件${check.matchedEvents}次,跨${check.distinctDays}日;仅证明本次条件下达到要求` : check.missing.join(';')}`),
358
+ ...state.evidencePrecisionWarnings.map((warning) => `证据精度:${warning}`),
331
359
  state.missingVerified.length ? `必验缺口:${state.missingVerified.join('、')}` : '必验节点已有记录;仍需判断能力成果是否达到,不能自动推进。',
332
360
  state.pendingNodes.length ? `先完成当前单元:${state.pendingNodes.join('、')}` : '',
333
361
  state.focusExpired ? '本周主线到期,先复盘并修订。' : '',
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,12 +127,44 @@ 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
+ }
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 } : {}) })
105
159
  }
160
+ return result
106
161
  }
107
162
 
108
- export function createStrategyStore({ 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
- const vpPath = join(dataDir, 'VP_EVENTS.yaml')
167
+ const vpPath = join(vpDir, 'VP_EVENTS.yaml')
111
168
  const validateTarget = (doc) => {
112
169
  const errors = []
113
170
  if (doc.version !== 1) errors.push('version 必须是 1')
@@ -142,19 +199,24 @@ export function createStrategyStore({ dataDir, competenciesPath, now = () => new
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, competenciesPath, now = () => new
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, competenciesPath, now = () => new
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,37 +312,44 @@ export function createStrategyStore({ dataDir, competenciesPath, now = () => new
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 } = {}) {
@@ -271,20 +374,21 @@ export function createStrategyStore({ dataDir, competenciesPath, now = () => new
271
374
  function evidence() {
272
375
  try {
273
376
  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
- }
377
+ const result = vpFacts(vp)
284
378
  for (const record of vp.postmortems) for (const competency of new Set(record.competencies)) {
285
379
  const id = `postmortem:${record.eventId}:${competency}`
286
380
  result.push({ id, eventId: record.eventId, source: 'postmortem', node: '', competency,
287
- date: record.date, signal: 'gap', summary: `复盘指出 ${competency}`, fingerprint: id })
381
+ date: record.date, signal: 'observation', summary: `复盘提及 ${competency},原因尚待核实`, fingerprint: id })
382
+ }
383
+ const facts = new Map(result.filter(entry => entry.source === 'vp').map(entry => [entry.id, entry]))
384
+ for (const record of vp.postmortems) for (const finding of record.findings ?? []) {
385
+ const cited = finding.evidenceRefs.map(id => facts.get(id))
386
+ if (!cited.length || cited.some(fact => !fact || fact.eventId !== record.eventId || (fact.competency && fact.competency !== finding.competency))) continue
387
+ const hash = createHash('sha256').update(JSON.stringify(finding)).digest('hex')
388
+ result.push({ id: `finding:${encodeURIComponent(record.eventId)}:${finding.competency}:${hash}`, eventId: record.eventId,
389
+ source: 'postmortem', node: '', competency: finding.competency, date: cited[0].date,
390
+ signal: finding.status === 'confirmed' ? 'gap' : 'observation',
391
+ summary: `${finding.status === 'confirmed' ? '复盘已确认' : finding.status === 'resolved' ? '复盘已解决' : '待确认假设'}:${finding.cause}`, fingerprint: hash })
288
392
  }
289
393
  return { ok: true, reject: '', evidence: result }
290
394
  } catch (err) { return { ok: false, reject: err.message, evidence: [] } }
@@ -296,10 +400,14 @@ export function createStrategyStore({ dataDir, competenciesPath, now = () => new
296
400
  const f = focus()
297
401
  return [
298
402
  `- 比赛目标:${t.contest},目标日期 ${t.date},结果目标 ${t.result},团队模式 ${t.teamMode}`,
403
+ `- 距目标日期:${f.daysRemaining}天;剩余时间影响训练取舍,不能只给所有能力乘同一紧急系数。`,
404
+ `- 比赛样本配置:${f.profile.title || '尚未选择'}(${f.profile.status});${f.profile.limitations.join(';')}`,
299
405
  `- 当前目标权重:${t.priorities.map((p) => `${p.id}(${p.weight})`).join('、')}`,
300
406
  f.focus?.length ? `- 本轮优先能力:${f.focus.map((x) => `${x.title}(${x.whyNow})`).join(';')}` : '- 当前没有可计算的能力重点。',
301
407
  f.postponed?.length ? `- 暂缓能力:${f.postponed.join('、')}` : '',
302
408
  '- 每次布置前先说明它服务哪个比赛能力、适用范围、迁移题和退出条件。',
409
+ '- 重点排序是目标权重与近30天已确认问题的参考;不是掌握度或金牌概率。先区分观察、假设、确认结论。',
410
+ '- 按长期能力目标→本次比赛目标→当前阶段→今天一个动作解释取舍;临近比赛不擅自覆盖长期目标。',
303
411
  ].filter(Boolean).join('\n')
304
412
  }
305
413
 
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()