acmer-coach 2.3.1 → 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/.github/workflows/publish.yml +2 -0
- package/README.md +32 -5
- package/assets/knowledge/COMPETENCIES.yaml +39 -4
- package/assets/knowledge/CONTEST_PROFILES.yaml +61 -0
- package/assets/knowledge/NODE_SCOPES.yaml +201 -0
- package/assets/rules/coach-rules.md +27 -3
- package/dev/catalog-check.mjs +74 -0
- package/dev/coaching-contract-check.mjs +93 -0
- package/dev/curriculum-check.mjs +193 -5
- package/dev/curriculum-evidence-check.mjs +9 -2
- package/dev/parity-check.mjs +9 -3
- package/dev/run-all.sh +5 -0
- package/dev/strategy-check.mjs +138 -4
- package/dev/vp-client-check.mjs +12 -2
- package/dev/vp-sync-check.mjs +122 -0
- package/dev/workspace-migration-check.mjs +92 -20
- package/dev.sh +8 -3
- package/docs//346/257/224/350/265/233/344/270/216/346/225/231/345/255/246/344/276/235/346/215/256.md +48 -0
- package/docs//350/256/276/350/256/241/346/226/271/351/222/210.md +17 -1
- package/docs//351/225/277/346/234/237/350/257/276/347/250/213/346/234/272/345/210/266.md +33 -6
- package/index.js +82 -51
- package/lib/curriculum-evidence.js +16 -2
- package/lib/curriculum-planning.js +75 -6
- package/lib/curriculum.js +44 -16
- package/lib/strategy.js +158 -50
- package/lib/vp-client.js +14 -2
- package/lib/workspace-migration.js +38 -0
- package/package.json +7 -1
- package/verify.mjs +1 -1
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
|
|
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:
|
|
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
|
|
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:
|
|
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 =
|
|
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
|
|
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:
|
|
67
|
-
solveMinutes:
|
|
68
|
-
attempts:
|
|
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 =
|
|
80
|
-
const problems = asArray(value.problems).map(normalizeProblem)
|
|
81
|
-
|
|
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
|
+
}
|
|
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, vpDir = dataDir, competenciesPath,
|
|
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
|
-
|
|
152
|
-
|
|
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
|
-
|
|
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
|
|
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, 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
|
|
238
|
-
const
|
|
239
|
-
const
|
|
240
|
-
|
|
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
|
-
|
|
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
|
|
248
|
-
|
|
249
|
-
|
|
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, vpDir = dataDir, competenciesPath
|
|
|
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: '
|
|
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, vpDir = dataDir, competenciesPath
|
|
|
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
|
-
|
|
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 { ...
|
|
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
|
+
}
|