acmer-coach 2.3.1 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/publish.yml +2 -0
- package/README.md +58 -8
- 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 +54 -17
- package/dev/catalog-check.mjs +74 -0
- package/dev/coaching-contract-check.mjs +219 -0
- package/dev/curriculum-check.mjs +194 -6
- package/dev/curriculum-evidence-check.mjs +9 -2
- package/dev/parity-check.mjs +23 -5
- package/dev/run-all.sh +9 -2
- package/dev/strategy-check.mjs +179 -11
- package/dev/teaching-check.mjs +191 -0
- 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 +79 -0
- package/docs//350/256/276/350/256/241/346/226/271/351/222/210.md +23 -6
- 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 +143 -83
- package/lib/curriculum-evidence.js +16 -2
- package/lib/curriculum-planning.js +75 -6
- package/lib/curriculum.js +44 -16
- package/lib/strategy.js +162 -60
- package/lib/teaching.js +78 -0
- package/lib/vp-client.js +14 -2
- package/lib/workspace-migration.js +38 -0
- package/package.json +8 -2
- package/verify-shape.mjs +35 -5
- package/verify.mjs +35 -16
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
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 ? '本周主线到期,先复盘并修订。' : '',
|