acmer-coach 2.3.1 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,46 @@
1
+ import { validOccurredAt } from './curriculum-evidence.js'
2
+
1
3
  const clean = (value) => String(value ?? '').trim()
2
4
  const text = (value, field, phase) => {
3
5
  const result = clean(value)
4
6
  if (result.length < 10) throw new Error(`阶段 ${phase} 的 ${field} 需要至少 10 字的具体依据。`)
5
7
  return result
6
8
  }
7
- const coreKeys = ['id', 'title', 'outcome', 'nodes', 'competencies', 'exitCriteria', 'requiredVerified']
9
+ const coreKeys = ['id', 'title', 'outcome', 'nodes', 'competencies', 'exitCriteria', 'exitChecks', 'requiredVerified']
8
10
  export const corePhase = (phase) => Object.fromEntries(coreKeys.map((key) => [key, phase[key]]))
9
11
 
12
+ function normalizeExitChecks(input, phase) {
13
+ if (input === undefined) return []
14
+ if (!Array.isArray(input) || input.length > 100) throw new Error(`阶段 ${phase.id} 的 exitChecks 必须是最多 100 条的数组。`)
15
+ const targets = new Set()
16
+ return input.map((check) => {
17
+ const node = clean(check?.node), competency = clean(check?.competency)
18
+ if (Boolean(node) === Boolean(competency) || (node && !phase.nodes.includes(node)) || (competency && !phase.competencies.includes(competency))) throw new Error(`阶段 ${phase.id} 的 exitChecks 必须指定一个属于本阶段的 node 或 competency。`)
19
+ const target = `${node ? 'node' : 'competency'}:${node || competency}`
20
+ if (targets.has(target)) throw new Error(`阶段 ${phase.id} 的 exitChecks 目标不能重复:${node || competency}。`)
21
+ targets.add(target)
22
+ if (!Number.isInteger(check.minEvents) || check.minEvents < 1 || check.minEvents > 1000) throw new Error(`阶段 ${phase.id} 的 exitChecks.minEvents 应为 1–1000 的整数。`)
23
+ const result = { ...(node ? { node } : { competency }), minEvents: check.minEvents }
24
+ if (check.minDistinctDays !== undefined) {
25
+ if (!Number.isInteger(check.minDistinctDays) || check.minDistinctDays < 1 || check.minDistinctDays > check.minEvents) throw new Error(`阶段 ${phase.id} 的 minDistinctDays 应为 1–minEvents 的整数。`)
26
+ result.minDistinctDays = check.minDistinctDays
27
+ }
28
+ if (check.independent !== undefined) {
29
+ if (typeof check.independent !== 'boolean') throw new Error(`阶段 ${phase.id} 的 independent 必须是布尔值,不能从说明猜测。`)
30
+ result.independent = check.independent
31
+ }
32
+ if (check.maxMinutes !== undefined) {
33
+ if (!Number.isFinite(check.maxMinutes) || check.maxMinutes <= 0) throw new Error(`阶段 ${phase.id} 的 maxMinutes 必须为正数。`)
34
+ result.maxMinutes = check.maxMinutes
35
+ }
36
+ if (check.metric !== undefined || check.maxValue !== undefined) {
37
+ if (typeof check.metric !== 'string' || !check.metric.trim() || !Number.isFinite(check.maxValue)) throw new Error(`阶段 ${phase.id} 的 metric 与数值 maxValue 必须一起填写。`)
38
+ result.metric = check.metric.trim(); result.maxValue = check.maxValue
39
+ }
40
+ return result
41
+ })
42
+ }
43
+
10
44
  export function prerequisitesOf(nodes, byId) {
11
45
  const result = new Set()
12
46
  const done = new Set()
@@ -29,7 +63,7 @@ export function prerequisitesOf(nodes, byId) {
29
63
  return [...result].sort()
30
64
  }
31
65
 
32
- export function normalizePhases(input, byId, { legacy = false, completed = [] } = {}) {
66
+ export function normalizePhases(input, byId, { legacy = false, completed = [], allowMissingChecks = false } = {}) {
33
67
  if (!Array.isArray(input) || input.length < 1 || input.length > 30) throw new Error('课程需要 1–30 个阶段。')
34
68
  const ids = new Set()
35
69
  return input.map((phase, index) => {
@@ -42,6 +76,7 @@ export function normalizePhases(input, byId, { legacy = false, completed = [] }
42
76
  requiredVerified: Array.isArray(phase?.requiredVerified) ? phase.requiredVerified.map(clean)
43
77
  : (kind === 'training' || kind === 'legacy' ? [...(phase?.nodes ?? [])].map(clean) : []),
44
78
  }
79
+ p.exitChecks = normalizeExitChecks(phase?.exitChecks, p)
45
80
  if (!p.id || p.id.length > 80 || ids.has(p.id)) throw new Error('阶段 id 必须非空且唯一,最长 80 字符。')
46
81
  ids.add(p.id)
47
82
  if (!p.title || !p.outcome || !p.exitCriteria) throw new Error(`阶段 ${p.id} 缺标题、能力成果或验收条件。`)
@@ -52,7 +87,7 @@ export function normalizePhases(input, byId, { legacy = false, completed = [] }
52
87
  if (legacy) return { ...p, kind, finding: 'unknown', basis: '', goalContribution: '', priorityReason: '',
53
88
  estimatedHours: 0, evidenceRefs: [], uncertainty: '', diagnosticMethod: '', prerequisites }
54
89
  if (kind === 'legacy' && completed[index]?.kind === 'legacy' &&
55
- JSON.stringify(corePhase(completed[index])) === JSON.stringify(p)) return { ...completed[index], prerequisites }
90
+ JSON.stringify(corePhase(completed[index])) === JSON.stringify(corePhase(p))) return { ...completed[index], prerequisites }
56
91
  if (!['diagnostic', 'training', 'provisional'].includes(kind)) throw new Error(`阶段 ${p.id} 必须声明 kind=diagnostic/training/provisional;旧路线需补齐编排依据。`)
57
92
  const finding = clean(phase.finding)
58
93
  if (kind === 'training' ? !['gap', 'extension'].includes(finding) : finding !== 'unknown') throw new Error(`阶段 ${p.id}:训练需声明 gap 或 extension;诊断/待定只能声明 unknown。`)
@@ -60,6 +95,10 @@ export function normalizePhases(input, byId, { legacy = false, completed = [] }
60
95
  const estimatedHours = phase.estimatedHours
61
96
  if (!Number.isFinite(estimatedHours) || estimatedHours < 0 || estimatedHours > 10000 || (kind !== 'provisional' && estimatedHours === 0)) throw new Error(`阶段 ${p.id} 必须给正数 estimatedHours;待定阶段可用 0 表示未估算。`)
62
97
  if (kind === 'training' && p.nodes.some((node) => !p.requiredVerified.includes(node))) throw new Error(`训练阶段 ${p.id} 的节点必须全部列入 requiredVerified,不能用空验收绕过检测。`)
98
+ const preserved = completed[index] && JSON.stringify(corePhase(completed[index])) === JSON.stringify(corePhase(p))
99
+ if (kind === 'training' && !allowMissingChecks && !preserved &&
100
+ (p.nodes.some((node) => !p.exitChecks.some((check) => check.node === node)) ||
101
+ p.competencies.some((competency) => !p.exitChecks.some((check) => check.competency === competency)))) throw new Error(`训练阶段 ${p.id} 的 exitChecks 必须覆盖每个节点和能力;文字验收不能代替可检查条件。`)
63
102
  if (kind !== 'training' && p.requiredVerified.length) throw new Error(`诊断/待定阶段 ${p.id} 不以既有 verified 验收,请使用新诊断记录。`)
64
103
  return { ...p, kind, finding, estimatedHours, prerequisites,
65
104
  basis: text(phase.basis, 'basis(观察或待确认问题)', p.id),
@@ -91,6 +130,8 @@ export function validatePlan(phases, { byId, progress, catalog, current = 0 }) {
91
130
  if (!nodeMatch && !competencyMatch) throw new Error(`阶段 ${phase.id} 引用了无关的证据记录「${ref}」。`)
92
131
  }
93
132
  if (phase.kind !== 'training') continue
133
+ if (phase.nodes.some((node) => !phase.exitChecks.some((check) => check.node === node)) ||
134
+ phase.competencies.some((competency) => !phase.exitChecks.some((check) => check.competency === competency))) throw new Error(`阶段 ${phase.id} 尚缺结构化 exitChecks;旧路线请 revise 补齐,不会自动猜测验收次数。`)
94
135
  const signal = phase.finding === 'gap' ? 'gap' : 'baseline'
95
136
  const uncovered = phase.nodes.filter((node) => !phase.evidenceRefs.some((ref) => {
96
137
  const entry = records.get(ref)
@@ -111,12 +152,40 @@ export function validatePlan(phases, { byId, progress, catalog, current = 0 }) {
111
152
  }
112
153
  }
113
154
 
114
- export function missingFreshEvidence(phase, catalog, entryEvidenceRefs, enteredDate, entryEvidenceEvents = []) {
155
+ const validEvidenceDate = (date, enteredDate, today) => typeof date === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(date) &&
156
+ Number.isFinite(Date.parse(`${date}T12:00:00Z`)) && new Date(`${date}T12:00:00Z`).toISOString().slice(0, 10) === date &&
157
+ date >= enteredDate && date <= today
158
+ const freshTiming = (entry, enteredDate, today, enteredAt, currentAt) => validEvidenceDate(entry.date, enteredDate, today) &&
159
+ (entry.occurredAt === undefined || (validOccurredAt(entry.occurredAt) &&
160
+ (!enteredAt || Date.parse(entry.occurredAt) >= Date.parse(enteredAt)) &&
161
+ (!currentAt || Date.parse(entry.occurredAt) <= Date.parse(currentAt))))
162
+
163
+ export function evaluateExitChecks(phase, catalog, entryEvidenceRefs, enteredDate, entryEvidenceEvents = [], today = '9999-12-31', enteredAt = '', currentAt = '') {
164
+ const old = new Set(entryEvidenceRefs), oldEvents = new Set(entryEvidenceEvents)
165
+ return (phase.exitChecks ?? []).map((check) => {
166
+ const events = new Map()
167
+ for (const entry of catalog) {
168
+ if ((check.node ? entry.node !== check.node : entry.competency !== check.competency) || entry.signal !== 'baseline' ||
169
+ !entry.eventId || old.has(entry.id) || oldEvents.has(entry.eventId) || !freshTiming(entry, enteredDate, today, enteredAt, currentAt)) continue
170
+ if (check.independent !== undefined && entry.independent !== check.independent) continue
171
+ if (check.maxMinutes !== undefined && (!Number.isFinite(entry.minutes) || entry.minutes <= 0 || entry.minutes > check.maxMinutes)) continue
172
+ if (check.metric && (entry.metric !== check.metric || !Number.isFinite(entry.value) || entry.value > check.maxValue)) continue
173
+ events.set(entry.eventId, entry)
174
+ }
175
+ const matchedEvents = events.size, distinctDays = new Set([...events.values()].map((entry) => entry.date)).size
176
+ const missing = []
177
+ if (matchedEvents < check.minEvents) missing.push(`合格新事件 ${matchedEvents}/${check.minEvents}`)
178
+ if (distinctDays < (check.minDistinctDays ?? 1)) missing.push(`不同日期 ${distinctDays}/${check.minDistinctDays ?? 1}`)
179
+ return { ...check, target: check.node || check.competency, matchedEvents, distinctDays, passed: missing.length === 0, missing }
180
+ })
181
+ }
182
+
183
+ export function missingFreshEvidence(phase, catalog, entryEvidenceRefs, enteredDate, entryEvidenceEvents = [], today = '9999-12-31', enteredAt = '', currentAt = '') {
115
184
  const old = new Set(entryEvidenceRefs)
116
185
  const events = new Set(entryEvidenceEvents)
117
186
  const missingNodes = phase.nodes.filter((node) => !catalog.some((entry) => entry.node === node && !old.has(entry.id) &&
118
- !events.has(entry.eventId) && entry.date >= enteredDate && (phase.kind === 'diagnostic' || entry.signal === 'baseline')))
187
+ !events.has(entry.eventId) && freshTiming(entry, enteredDate, today, enteredAt, currentAt) && (phase.kind === 'diagnostic' || entry.signal === 'baseline')))
119
188
  const missingCompetencies = phase.competencies.filter((competency) => !catalog.some((entry) => entry.competency === competency && !old.has(entry.id) &&
120
- !events.has(entry.eventId) && entry.date >= enteredDate && (phase.kind === 'diagnostic' || entry.signal === 'baseline')))
189
+ !events.has(entry.eventId) && freshTiming(entry, enteredDate, today, enteredAt, currentAt) && (phase.kind === 'diagnostic' || entry.signal === 'baseline')))
121
190
  return [...missingNodes, ...missingCompetencies]
122
191
  }
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 ? '本周主线到期,先复盘并修订。' : '',