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.
@@ -24,6 +24,7 @@ const phases = [
24
24
  { id: 'foundation', title: '稳定基础能力', outcome: '独立建模与实现', nodes: ['B', 'C'], exitCriteria: '独立变式与延迟复测', requiredVerified: ['B', 'C'] },
25
25
  { id: 'transfer', title: '组合迁移', outcome: '识别组合题机制', nodes: ['D'], exitCriteria: '无标签混合题独立完成', requiredVerified: ['D'] },
26
26
  ].map((phase) => ({ ...phase, kind: 'training', finding: 'gap',
27
+ exitChecks: phase.nodes.map((node) => ({ node, minEvents: 1 })),
27
28
  basis: '已存在对应知识点的未通过检测记录,需要针对性训练',
28
29
  goalContribution: '通过独立建模和稳定实现提升区域赛可完成题数',
29
30
  priorityReason: '先修复已观测的基础问题,再进入有依赖的组合训练',
@@ -38,20 +39,21 @@ function prove(progress, node) {
38
39
  function fixture() {
39
40
  const dataDir = mkdtempSync(join(testBase, 'coach-curriculum-'))
40
41
  const progress = { nodes: structuredClone(seedNodes), pendingActions: {} }
42
+ const clock = { at: '2026-10-09T12:00:00+08:00' }
41
43
  const options = { dataDir, loadMap: () => ({ byId }), loadProgress: () => progress,
42
- now: () => new Date('2026-10-09T12:00:00+08:00') }
44
+ now: () => new Date(clock.at) }
43
45
  const store = createCurriculumStore(options)
44
46
  const create = (extra = {}) => store.execute({ action: 'create', expectedRevision: 0,
45
47
  goal: '区域赛稳定表现,长期向 WF 发展', baseline: '水平与队伍缺口已核实,尚需独立检测',
46
48
  weeklyHours: 30, phases, reason: '根据用户目标与真实训练表现建立课程路线', ...extra })
47
- return { dataDir, progress, options, store, create }
49
+ return { dataDir, progress, options, clock, store, create }
48
50
  }
49
51
  function validateShape(schema, value) {
50
52
  if (schema.type === 'object') {
51
53
  assert.equal(typeof value, 'object'); assert.notEqual(value, null)
52
54
  if (schema.additionalProperties === false) for (const key of Object.keys(value)) assert.ok(key in schema.properties, `unexpected ${key}`)
53
55
  for (const [key, child] of Object.entries(schema.properties)) {
54
- if (child.required) assert.ok(key in value, `missing ${key}`)
56
+ if (child.required === true || (Array.isArray(schema.required) && schema.required.includes(key))) assert.ok(key in value, `missing ${key}`)
55
57
  if (key in value) validateShape(child, value[key])
56
58
  }
57
59
  } else if (schema.type === 'array') {
@@ -61,21 +63,203 @@ function validateShape(schema, value) {
61
63
  }
62
64
 
63
65
  const phaseFor = (nodes, extra = {}) => ({ ...phases[0], nodes, requiredVerified: [...nodes],
66
+ exitChecks: nodes.map((node) => ({ node, minEvents: 1 })),
64
67
  evidenceRefs: seedEvidence.filter((entry) => nodes.includes(entry.node)).map((entry) => entry.id), ...extra })
65
68
  const diagnostic = (nodes = ['B']) => phaseFor(nodes, {
66
69
  id: 'diagnostic', title: '核实当前能力缺口', kind: 'diagnostic', finding: 'unknown',
67
70
  basis: '尚无足够的实际结果证明具体短板,先进行诊断',
68
71
  uncertainty: '尚未确认是否独立建模、实现稳定性或熟练度存在缺口',
69
72
  diagnosticMethod: '独立完成小规模变式,记录结果并对失败代码进行判因',
70
- evidenceRefs: [], requiredVerified: [], estimatedHours: 2,
73
+ evidenceRefs: [], requiredVerified: [], exitChecks: [], estimatedHours: 2,
71
74
  })
72
75
  const provisional = (nodes = ['D']) => phaseFor(nodes, {
73
76
  id: 'future', kind: 'provisional', finding: 'unknown', basis: '该方向目前仅是与目标相关的远期假设,尚待诊断',
74
77
  uncertainty: '尚未取得这些节点的训练结果,进入前须重新核实依据',
75
- evidenceRefs: [], requiredVerified: [], estimatedHours: 0,
78
+ evidenceRefs: [], requiredVerified: [], exitChecks: [], estimatedHours: 0,
76
79
  })
77
80
  const advance = (f, revision = f.store.snapshot().revision) => f.store.execute({ action: 'advance', expectedRevision: revision, reason: '依据本阶段实际产生的检测与训练记录评估成果' })
78
81
 
82
+ test('new training stages reject narrative-only exit criteria', () => {
83
+ const f = fixture()
84
+ const phase = phaseFor(['B'])
85
+ delete phase.exitChecks
86
+ const result = f.create({ phases: [phase] })
87
+ assert.equal(result.ok, false)
88
+ assert.match(result.reject, /exitChecks/)
89
+ assert.equal(f.store.load(), null)
90
+ })
91
+
92
+ test('structured exits count distinct fresh events and delayed independent performance', () => {
93
+ const f = fixture()
94
+ const phase = phaseFor(['B'], { exitChecks: [{ node: 'B', minEvents: 2, minDistinctDays: 2, independent: true, maxMinutes: 45 }] })
95
+ assert.equal(f.create({ phases: [phase] }).ok, true)
96
+ f.progress.nodes.B.status = 'verified'
97
+ f.progress.nodes.B.passes = [{ date: '2026-10-09', problemId: 'P1', solved: true, independent: true, minutes: 30 }]
98
+ let state = f.store.execute({ action: 'read' })
99
+ assert.equal(advance(f).ok, false)
100
+ assert.equal(state.exitCheckResults[0].matchedEvents, 1)
101
+ f.progress.nodes.B.passes.push({ date: '2026-10-09', problemId: 'P2', solved: true, independent: true, minutes: 30 })
102
+ state = f.store.execute({ action: 'read' })
103
+ assert.equal(state.exitCheckResults[0].matchedEvents, 2)
104
+ assert.equal(state.exitCheckResults[0].distinctDays, 1)
105
+ assert.equal(advance(f).ok, false)
106
+ f.clock.at = '2026-10-10T12:00:00+08:00'
107
+ f.progress.nodes.B.passes.push({ date: '2026-10-10', problemId: 'P3', solved: true, independent: true, minutes: 60 })
108
+ assert.equal(advance(f).ok, false)
109
+ f.progress.nodes.B.passes.push({ date: '2026-10-10', problemId: 'P4', solved: true, independent: true, minutes: 35 })
110
+ assert.equal(advance(f).status, 'complete')
111
+ })
112
+
113
+ test('structured exits reject missing facts, repeated events and edits to old successes', () => {
114
+ const f = fixture()
115
+ f.progress.nodes.B.passes = [{ date: '2026-10-09', problemId: 'P-old', solved: true, independent: true, minutes: 90 }]
116
+ const phase = phaseFor(['B'], { exitChecks: [{ node: 'B', minEvents: 2, independent: true, maxMinutes: 45 }] })
117
+ assert.equal(f.create({ phases: [phase] }).ok, true)
118
+ f.progress.nodes.B.status = 'verified'
119
+ f.progress.nodes.B.passes[0].minutes = 30
120
+ prove(f.progress, 'B') // A passed check without an explicit independence fact cannot satisfy this rule.
121
+ assert.equal(f.store.execute({ action: 'read' }).exitCheckResults[0].matchedEvents, 0)
122
+ const fresh = { date: '2026-10-09', problemId: 'P-new', solved: true, independent: true, minutes: 25 }
123
+ f.progress.nodes.B.passes.push(fresh, structuredClone(fresh))
124
+ assert.equal(f.store.execute({ action: 'read' }).exitCheckResults[0].matchedEvents, 1)
125
+ assert.equal(advance(f).ok, false)
126
+ f.progress.nodes.B.passes.push({ ...fresh, problemId: 'P-another' })
127
+ assert.equal(advance(f).status, 'complete')
128
+ })
129
+
130
+ test('changing exit requirements resets the entry snapshot rather than reusing current success', () => {
131
+ const f = fixture(); assert.equal(f.create({ phases: [phaseFor(['B'])] }).ok, true)
132
+ prove(f.progress, 'B')
133
+ assert.deepEqual(f.store.execute({ action: 'read' }).missingFreshEvidence, [])
134
+ const phase = phaseFor(['B'], { exitChecks: [{ node: 'B', minEvents: 2 }] })
135
+ const result = f.store.execute({ action: 'revise', expectedRevision: 1, phases: [phase], reason: '原单次检测不足以证明稳定性,追加两次新检测条件' })
136
+ assert.equal(result.ok, true, result.reject)
137
+ assert.equal(result.exitCheckResults[0].matchedEvents, 0)
138
+ prove(f.progress, 'B'); assert.equal(advance(f).ok, false)
139
+ prove(f.progress, 'B'); assert.equal(advance(f).status, 'complete')
140
+ })
141
+
142
+ test('v2 courses remain unchanged on read and completed v2 phases survive explicit v3 repair', () => {
143
+ const f = fixture(); assert.equal(f.create().ok, true)
144
+ prove(f.progress, 'B'); prove(f.progress, 'C')
145
+ const old = parse(readFileSync(f.store.path, 'utf8'))
146
+ old.version = 2; old.current = 1; old.focusNodes = []
147
+ old.phases.forEach((phase) => { delete phase.exitChecks })
148
+ const bytes = stringify(old); writeFileSync(f.store.path, bytes)
149
+ const result = f.store.execute({ action: 'read' })
150
+ assert.equal(result.ok, true); assert.equal(result.needsReview, true)
151
+ assert.equal(readFileSync(f.store.path, 'utf8'), bytes)
152
+ assert.equal(f.store.assignment({ node: 'D', revision: 1 }).ok, false)
153
+ assert.equal(advance(f).ok, false)
154
+ const completed = f.store.load().phases[0]
155
+ const repaired = f.store.execute({ action: 'revise', expectedRevision: 1, phases: [completed, phases[1]], reason: '只为未完成阶段补可检查条件,保留已完成阶段原始依据' })
156
+ assert.equal(repaired.ok, true, repaired.reject)
157
+ assert.equal(repaired.exitCheckResults[0].matchedEvents, 0)
158
+ const saved = parse(readFileSync(f.store.path, 'utf8'))
159
+ assert.equal(saved.version, 3)
160
+ assert.deepEqual(saved.phases[0], completed)
161
+ assert.equal(saved.history.at(-1).previous.version, 2)
162
+ })
163
+
164
+ test('VP metric exits require baseline facts with the exact metric and finite value', () => {
165
+ const f = fixture()
166
+ const facts = [{ id: 'vp:old', eventId: 'old', source: 'vp', node: '', competency: 'contest-reading', date: '2026-10-08', signal: 'gap', summary: '已确认的漏读', fingerprint: 'old' }]
167
+ const store = createCurriculumStore({ ...f.options, loadStrategyEvidence: () => facts })
168
+ const phase = { ...phaseFor([]), id: 'vp-reading', competencies: ['contest-reading'], evidenceRefs: ['vp:old'],
169
+ exitChecks: [{ competency: 'contest-reading', minEvents: 2, metric: 'missed-reading', maxValue: 0 }] }
170
+ assert.equal(store.execute({ action: 'create', expectedRevision: 0, goal: '区域赛稳定读题', phases: [phase], reason: '依据已确认的漏读缺口,设两场零漏读的事实验收' }).ok, true)
171
+ const fresh = { ...facts[0], eventId: 'new', id: 'vp:new', date: '2026-10-09', signal: 'baseline' }
172
+ facts.push(fresh, { ...fresh, eventId: 'observation', id: 'vp:observation', signal: 'observation', metric: 'missed-reading', value: 0 })
173
+ assert.equal(store.execute({ action: 'read' }).exitCheckResults[0].matchedEvents, 0)
174
+ facts.push({ ...fresh, id: 'vp:new-measured', metric: 'missed-reading', value: 0 }, { ...fresh, id: 'vp:duplicate', metric: 'missed-reading', value: 0 })
175
+ assert.equal(store.execute({ action: 'read' }).exitCheckResults[0].matchedEvents, 1)
176
+ assert.equal(store.execute({ action: 'advance', expectedRevision: 1, reason: '只有一场合格的新事实,验证不会误判两场条件' }).ok, false)
177
+ facts.push({ ...fresh, id: 'vp:another', eventId: 'another', metric: 'missed-reading', value: 0 })
178
+ assert.equal(store.execute({ action: 'advance', expectedRevision: 1, reason: '两场不同的新事件都有零漏读的明确指标事实' }).status, 'complete')
179
+ })
180
+
181
+ test('malformed or uncovered structured exit rules are rejected before any write', () => {
182
+ for (const exitChecks of [[], [{ node: 'B', minEvents: 0 }], [{ node: 'C', minEvents: 1 }],
183
+ [{ node: 'B', competency: 'contest-reading', minEvents: 1 }], [{ node: 'B', minEvents: 1, minDistinctDays: 2 }],
184
+ [{ node: 'B', minEvents: 1, independent: 'true' }], [{ node: 'B', minEvents: 1, maxMinutes: 0 }],
185
+ [{ node: 'B', minEvents: 1, metric: 'missed-reading' }], [{ node: 'B', minEvents: 1, maxValue: 0 }],
186
+ [{ node: 'B', minEvents: 1 }, { node: 'B', minEvents: 2 }]]) {
187
+ const f = fixture(); const result = f.create({ phases: [phaseFor(['B'], { exitChecks })] })
188
+ assert.equal(result.ok, false, JSON.stringify(exitChecks))
189
+ assert.equal(f.store.load(), null)
190
+ }
191
+ })
192
+
193
+ test('future-dated events cannot meet training or diagnostic exit gates', () => {
194
+ for (const phase of [phaseFor(['B']), diagnostic()]) {
195
+ const f = fixture(); assert.equal(f.create({ phases: [phase] }).ok, true)
196
+ f.progress.nodes.B.status = 'verified'
197
+ f.progress.nodes.B.checks.push({ date: '2026-10-10', outcome: 'passed' })
198
+ assert.deepEqual(f.store.execute({ action: 'read' }).missingFreshEvidence, ['B'])
199
+ assert.equal(advance(f).ok, false)
200
+ f.clock.at = '2026-10-10T12:00:00+08:00'
201
+ assert.deepEqual(f.store.execute({ action: 'read' }).missingFreshEvidence, [])
202
+ }
203
+ })
204
+
205
+ test('exact occurrence times reject same-day earlier imports and future or malformed timestamps', () => {
206
+ const f = fixture(); assert.equal(f.create({ phases: [phaseFor(['B'])] }).ok, true)
207
+ f.progress.nodes.B.status = 'verified'
208
+ f.progress.nodes.B.passes = [
209
+ { date: '2026-10-09', problemId: 'P-earlier', solved: true, independent: true, minutes: 20, occurredAt: '2026-10-09T10:00:00+08:00' },
210
+ { date: '2026-10-09', problemId: 'P-future', solved: true, independent: true, minutes: 20, occurredAt: '2026-10-09T14:00:00+08:00' },
211
+ { date: '2026-10-09', problemId: 'P-invalid', solved: true, independent: true, minutes: 20, occurredAt: 'today' },
212
+ ]
213
+ assert.equal(f.store.execute({ action: 'read' }).exitCheckResults[0].matchedEvents, 0)
214
+ f.clock.at = '2026-10-09T15:00:00+08:00'
215
+ assert.equal(f.store.execute({ action: 'read' }).exitCheckResults[0].matchedEvents, 1)
216
+ assert.equal(advance(f).status, 'complete')
217
+ })
218
+
219
+ test('date-only performance exposes its timing precision instead of claiming exact chronology', () => {
220
+ const f = fixture(); assert.equal(f.create({ phases: [phaseFor(['B'])] }).ok, true)
221
+ prove(f.progress, 'B')
222
+ const result = f.store.execute({ action: 'read' })
223
+ assert.equal(result.exitCheckResults[0].matchedEvents, 1)
224
+ assert.ok(result.evidencePrecisionWarnings.some((warning) => warning.includes('同日')))
225
+ validateShape(curriculumTool(f.store).output.schema, result)
226
+ })
227
+
228
+ test('assess can retrieve the oldest unlabeled VP facts beyond the 200-item response window', () => {
229
+ const f = fixture(); f.progress.nodes = {}
230
+ const facts = Array.from({ length: 201 }, (_, index) => ({ id: `vp:fact-${index}`, eventId: `vp:contest-${index}`, source: 'vp',
231
+ node: '', competency: '', date: '2026-10-09', signal: 'observation', summary: `第${index}场自动同步事实`, fingerprint: `fact-${index}` }))
232
+ const store = createCurriculumStore({ ...f.options, loadStrategyEvidence: () => facts })
233
+ const all = store.execute({ action: 'assess' })
234
+ assert.equal(all.evidenceCount, 201); assert.equal(all.evidenceTruncated, true)
235
+ assert.ok(all.evidence.every((entry) => entry.eventId !== 'vp:contest-0'))
236
+ const tool = curriculumTool(store)
237
+ const selected = tool.execute({ action: 'assess', evidenceEventIds: ['vp:contest-0'] })
238
+ assert.equal(selected.ok, true, selected.reject)
239
+ assert.equal(selected.evidenceCount, 1); assert.equal(selected.evidenceTruncated, false)
240
+ assert.deepEqual(selected.evidence.map((entry) => entry.id), ['vp:fact-0'])
241
+ assert.ok(tool.parameters.evidenceEventIds)
242
+ validateShape(tool.output.schema, selected)
243
+ assert.equal(store.execute({ action: 'assess', evidenceEventIds: ['vp:contest-0'], evidenceNodes: ['B'] }).evidenceCount, 0)
244
+ assert.equal(store.execute({ action: 'assess', evidenceEventIds: ['vp:does-not-exist'] }).evidenceCount, 0)
245
+ assert.equal(store.execute({ action: 'assess' }).evidenceCount, 201)
246
+ })
247
+
248
+ test('assess rejects malformed event filters without changing the curriculum or exit evidence', () => {
249
+ const f = fixture(); assert.equal(f.create({ phases: [phaseFor(['B'])] }).ok, true)
250
+ const before = readFileSync(f.store.path, 'utf8')
251
+ for (const evidenceEventIds of ['vp:first', null, [1], [''], [' ']]) {
252
+ const result = f.store.execute({ action: 'assess', evidenceEventIds })
253
+ assert.equal(result.ok, false, JSON.stringify(evidenceEventIds))
254
+ assert.match(result.reject, /evidenceEventIds/)
255
+ }
256
+ prove(f.progress, 'B')
257
+ assert.equal(f.store.execute({ action: 'assess', evidenceEventIds: ['vp:unrelated'] }).evidenceCount, 0)
258
+ assert.deepEqual(f.store.execute({ action: 'read' }).missingFreshEvidence, [])
259
+ assert.equal(readFileSync(f.store.path, 'utf8'), before)
260
+ assert.equal(advance(f).status, 'complete')
261
+ })
262
+
79
263
  test('assess exposes only existing record refs; rating and statuses cannot justify a training stage', () => {
80
264
  const f = fixture(); f.progress.nodes = { A: { status: 'learned' }, B: { status: 'studying', rating: 1600 } }
81
265
  const assessed = f.store.execute({ action: 'assess' })
@@ -220,7 +404,7 @@ test('v1 route remains readable without writes; migration preserves completed st
220
404
  const result = f.store.execute({ action: 'revise', expectedRevision: 1, phases: [completed, phases[1]], reason: '为旧路线补齐未完成阶段的真实依据,保留已经完成的阶段' })
221
405
  assert.equal(result.ok, true, result.reject); assert.equal(result.revision, 2)
222
406
  const migrated = parse(readFileSync(f.store.path, 'utf8'))
223
- assert.equal(migrated.version, 2); assert.equal(migrated.phases[0].kind, 'legacy')
407
+ assert.equal(migrated.version, 3); assert.equal(migrated.phases[0].kind, 'legacy')
224
408
  assert.equal(migrated.history[1].previous.version, 1)
225
409
  })
226
410
 
@@ -347,6 +531,7 @@ test('competency-only phases can use fresh VP evidence as their exit gate', () =
347
531
  basis: 'VP 已记录读题缺口,需要在真实比赛节奏中修复', goalContribution: '提高区域赛前半小时的可做题识别和取舍质量',
348
532
  priorityReason: '它直接决定队伍是否把时间用在能拿分的题上', estimatedHours: 6, uncertainty: '', diagnosticMethod: '',
349
533
  evidenceRefs: ['vp:old:contest-reading'],
534
+ exitChecks: [{ competency: 'contest-reading', minEvents: 1 }],
350
535
  }
351
536
  assert.equal(store.execute({ action: 'create', expectedRevision: 0, goal: '区域赛稳定表现', baseline: '已有一场 VP 的读题缺口记录', weeklyHours: 30, phases: [phase], reason: '根据 VP 的读题记录建立能力课程' }).ok, true)
352
537
  assert.deepEqual(store.snapshot().phaseCompetencies, ['contest-reading'])
@@ -389,6 +574,9 @@ test('plugin integration: cross-branch course assignments, detours, logging and
389
574
  const course = tools.get('coach_curriculum'); const assign = tools.get('coach_assign')
390
575
  const created = await course.execute({ action: 'create', expectedRevision: 0, goal: '测试长期路线', phases, reason: '依据训练表现建立可延续的阶段课程' })
391
576
  assert.equal(created.ok, true, created.reject)
577
+ const selectedEvidence = await course.execute({ action: 'assess', evidenceEventIds: ['check:B:0'] })
578
+ assert.equal(selectedEvidence.evidenceCount, 1)
579
+ assert.deepEqual(selectedEvidence.evidence.map((entry) => entry.eventId), ['check:B:0'])
392
580
  const candidates = await tools.get('coach_next').execute({ cursor: 'B' })
393
581
  const nextText = tools.get('coach_next').output.render({}, candidates)[0].text
394
582
  assert.match(nextText, /测试长期路线/)
@@ -37,8 +37,7 @@ test('catalog only contains real record entries, with a stable typed public shap
37
37
  const rows = rowsFor({ checks: [check()], diagnoses: [diagnosis()], passes: [practice()] })
38
38
  assert.equal(rows.length, 3)
39
39
  for (const row of rows) {
40
- assert.deepEqual(Object.keys(row).sort(), ['id', 'eventId', 'source', 'node', 'date', 'signal', 'summary', 'fingerprint'].sort())
41
- for (const key of Object.keys(row)) assert.equal(typeof row[key], 'string', key)
40
+ for (const key of ['id', 'eventId', 'source', 'node', 'date', 'signal', 'summary', 'fingerprint']) assert.equal(typeof row[key], 'string', key)
42
41
  assert.ok(row.id && row.eventId && row.fingerprint && row.summary)
43
42
  assert.equal(row.node, '区间 DP'); assert.equal(row.date, date)
44
43
  assert.ok(['check', 'diagnosis', 'practice', 'schedule'].includes(row.source))
@@ -52,6 +51,14 @@ test('failed and over-time checks are gaps; passed checks only establish a basel
52
51
  assert.deepEqual(rows.map((row) => row.source), ['check', 'check', 'check'])
53
52
  })
54
53
 
54
+ test('structured performance fields are explicit facts rather than narrative guesses', () => {
55
+ const rows = rowsFor({ checks: [check('passed')], passes: [practice({ solved: true, independent: true, minutes: 32 })] })
56
+ assert.equal(rows[0].minutes, 45)
57
+ assert.equal(rows[0].independent, undefined)
58
+ assert.equal(rows[1].minutes, 32)
59
+ assert.equal(rows[1].independent, true)
60
+ })
61
+
55
62
  test('abandoned checks and narrative diagnoses without code evidence never become gaps', () => {
56
63
  assert.deepEqual(rowsFor({ checks: [check('abandoned', { reason: '今天临时没时间' })],
57
64
  diagnoses: [diagnosis({ evidence: [] }), diagnosis({ evidence: undefined }),
@@ -73,8 +73,8 @@ const onlyA = namesA.filter((n) => !namesB.includes(n))
73
73
  check('开源版没有丢掉任何原有工具', onlyB.length === 0, onlyB.join(',') || '(没丢)')
74
74
  // 新增的只能是「搭环境 / 拉数据 / 查知识 / 长期课程」这几类 —— 它们补的正是
75
75
  // 开源版缺的那些基础设施(原版靠本地已有的库和 skill,别人没有)。
76
- const EXPECTED_NEW = new Set(['coach_setup', 'coach_wiki', 'coach_import', 'coach_curriculum'])
77
- check('新增的都在预期内(搭建 / 检索 / 导入 / 长期课程)',
76
+ const EXPECTED_NEW = new Set(['coach_setup', 'coach_wiki', 'coach_import', 'coach_curriculum', 'coach_note', 'coach_target', 'coach_focus', 'coach_scope', 'coach_postmortem', 'coach_vp_import', 'coach_vp_connect', 'coach_vp_sync', 'coach_vp_contests', 'coach_vp_replay'])
77
+ check('新增的都在预期内(搭建 / 检索 / 课程 / 比赛 / VP)',
78
78
  onlyA.every((n) => EXPECTED_NEW.has(n)), onlyA.join(',') || '(无新增)')
79
79
  // 对照副本可能也已经升级到 20/21 个工具;检查原工具集合,不能拿固定数量拒绝新版。
80
80
  const ORIGINAL_TOOLS = ['coach_ping', 'coach_next', 'coach_assign', 'coach_status',
@@ -128,6 +128,16 @@ for (const [tool, args, label] of CALLS) {
128
128
  let ra, rb, ea, eb
129
129
  try { ra = norm(await ta.execute(args, undefined)) } catch (e) { ea = e.message }
130
130
  try { rb = norm(await tb.execute(args, undefined)) } catch (e) { eb = e.message }
131
+ // Legacy hosts have no goal layer; only its explicit no-target defaults may be absent.
132
+ if (tool === 'coach_assign' && rb && !('targetAligned' in rb) &&
133
+ ra?.targetAligned === false &&
134
+ Array.isArray(ra.competencies) && !ra.competencies.length) {
135
+ delete ra.targetAligned; delete ra.competencies
136
+ }
137
+ // A legacy host cannot return the new empty continuation field.
138
+ if (tool === 'coach_status' && rb && !('teachingContext' in rb) && ra?.teachingContext === '') {
139
+ delete ra.teachingContext
140
+ }
131
141
  const sa = JSON.stringify(ra ?? { ERR: ea }), sb = JSON.stringify(rb ?? { ERR: eb })
132
142
  if (sa === sb) { check(`${tool} · ${label}`, true) }
133
143
  else {
@@ -136,7 +146,7 @@ for (const [tool, args, label] of CALLS) {
136
146
  const dk = [...new Set([...ka, ...kb])].filter((k) => JSON.stringify(ra?.[k]) !== JSON.stringify(rb?.[k]))
137
147
  const benign = dk.every((k) => ALLOWED.some(([n]) => k === n))
138
148
  if (benign) { check(`${tool} · ${label}`, true, `仅包名/版本不同(${dk.join(',')})`) }
139
- else { diff++; check(`${tool} · ${label}`, false, `差异字段:${dk.join(', ')}`) }
149
+ else { diff++; check(`${tool} · ${label}`, false, `差异字段:${dk.join(', ')}`); console.log(JSON.stringify({ current: Object.fromEntries(dk.map(k => [k, ra?.[k]])), legacy: Object.fromEntries(dk.map(k => [k, rb?.[k]])) })) }
140
150
  }
141
151
  }
142
152
 
@@ -148,8 +158,16 @@ for (const [tool, args, label] of CALLS.filter((c) => c[0] !== 'coach_diagnose')
148
158
  try { va = await ta.execute(args, undefined) } catch { continue }
149
159
  try { vb = await tb.execute(args, undefined) } catch { continue }
150
160
  const ra = strip(ta.output.render(args, va)[0]?.text ?? '')
151
- const rb = strip(tb.output.render(args, vb)[0]?.text ?? '')
152
- const same = ra === rb
161
+ const legacyText = tb.output.render(args, vb)[0]?.text ?? ''
162
+ // The old lecture label overstated its evidence; only this intentional label changes.
163
+ const rb = strip(tool === 'coach_status'
164
+ ? legacyText.replace(/ 讲于 (\d{4}-\d{2}-\d{2})/g, ' 接触记录 $1(不证明理解)')
165
+ : legacyText)
166
+ // Only the changed goal-led advice is excluded; schedule facts still match byte for byte.
167
+ const scheduleAdvice = (text) => text
168
+ .replace(/^⚠️ \*\*复习别把新知识挤没了。\*\*.*剩下的时间留给推进。$/m, '<COACH_ADVICE>')
169
+ .replace(/^根据目标、剩余时间和实际表现,选择新课、复习、迁移或 VP。上面的间隔只供判断;不要为了填满课表持续开新知识点。$/m, '<COACH_ADVICE>')
170
+ const same = tool === 'coach_schedule' ? scheduleAdvice(ra) === scheduleAdvice(rb) : ra === rb
153
171
  check(`渲染 · ${tool} · ${label}`, same, same ? '' : '文本不一致')
154
172
  }
155
173
 
package/dev/run-all.sh CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bash
2
2
  # 一条命令跑全套验收。
3
3
  #
4
- # 八套:
4
+ # 验收项:
5
5
  # verify / verify-shape 主验收和全工具形状扫描
6
6
  # w1-check 解绑、夹具闸门、规则注入
7
7
  # wiki-check OI Wiki 检索
@@ -9,6 +9,7 @@
9
9
  # curriculum-check 长期课程、版本、阶段验收与任务关联
10
10
  # curriculum-evidence 编排证据目录、引用与事件身份
11
11
  # strategy-check 比赛目标、VP 证据、复盘与重点选择
12
+ # teaching 续课笔记、学生原话与进度保留
12
13
  # parity-check 和线上那份的保真度对比
13
14
  #
14
15
  # 主验收跑的是**已安装副本**(不是源码)—— 那才是运行时真正加载的东西。
@@ -46,7 +47,7 @@ run() { # run <名字> <命令...>
46
47
  echo "✓ $(echo "$out" | tail -1)"
47
48
  else
48
49
  echo "✗"
49
- echo "$out" | tail -25 | sed 's/^/ /'
50
+ echo "$out" | sed 's/^/ /'
50
51
  RC=1
51
52
  fi
52
53
  }
@@ -65,6 +66,12 @@ run "setup-check" node dev/setup-check.mjs
65
66
  run "curriculum" node --test dev/curriculum-check.mjs
66
67
  run "evidence" node --test dev/curriculum-evidence-check.mjs
67
68
  run "strategy" node --test dev/strategy-check.mjs
69
+ run "catalog" node --test dev/catalog-check.mjs
70
+ run "coach-contract" node --test dev/coaching-contract-check.mjs
71
+ run "teaching" node --test dev/teaching-check.mjs
72
+ run "VP-client" node dev/vp-client-check.mjs
73
+ run "VP-sync" node --test dev/vp-sync-check.mjs
74
+ run "migration" node --test dev/workspace-migration-check.mjs
68
75
  if [ "${COACH_LIVE_INDEX+x}" = x ] || node -e "
69
76
  const fs=require('fs'), os=require('os'), path=require('path');
70
77
  process.exit(fs.existsSync(path.join(os.homedir(), '.dsh', 'profiles', 'web', 'node_modules', 'dsh-coach', 'index.js')) ? 0 : 1);
@@ -5,15 +5,149 @@ import { join } from 'node:path'
5
5
  import { tmpdir } from 'node:os'
6
6
  import { createStrategyStore } from '../lib/strategy.js'
7
7
 
8
- function fixture() {
8
+ function fixture(options = {}) {
9
9
  const dir = mkdtempSync(join(tmpdir(), 'acmer-coach-strategy-'))
10
10
  const store = createStrategyStore({
11
11
  dataDir: dir,
12
12
  competenciesPath: join(process.cwd(), 'assets', 'knowledge', 'COMPETENCIES.yaml'),
13
+ ...options,
13
14
  })
14
15
  return { dir, store }
15
16
  }
16
17
 
18
+ test('invalid target constraints and VP numbers are rejected before changing ledgers', () => {
19
+ const { dir, store } = fixture()
20
+ try {
21
+ const target = { expectedRevision: 0, contest: 'VP', date: '2026-10-17', result: '稳定', weeklyHours: 25, priorities: [{ id: 'graph-modeling', weight: 10 }] }
22
+ for (const constraints of [[], null, 'bad']) assert.equal(store.target('set', { ...target, constraints }).ok, false)
23
+ assert.equal(store.target().ok, true)
24
+ assert.equal(store.target().revision, 0)
25
+ const event = { eventId: 'bad-numbers', contest: 'VP', date: '2026-10-10', problems: [{ problemId: 'A', status: 'AC' }] }
26
+ for (const [key, value] of [['solveMinutes', 'Infinity'], ['readMinutes', -1], ['attempts', 1.5], ['independent', 'yes'], ['occurredAt', 'yesterday']]) {
27
+ assert.equal(store.vpImport([{ ...event, problems: [{ ...event.problems[0], [key]: value }] }]).ok, false, key)
28
+ }
29
+ assert.equal(store.vpImport([{ ...event, durationMinutes: 'Infinity' }]).ok, false)
30
+ assert.equal(store.loadVp().revision, 0)
31
+ } finally { rmSync(dir, { recursive: true, force: true }) }
32
+ })
33
+
34
+ test('local training day includes morning VP evidence and computes the local deadline', () => {
35
+ const { dir, store } = fixture({ now: () => new Date(2026, 9, 11, 0, 30) })
36
+ try {
37
+ store.target('set', { expectedRevision: 0, contest: 'VP', date: '2026-10-17', result: '稳定', weeklyHours: 25, priorities: [{ id: 'graph-modeling', weight: 10 }] })
38
+ store.vpImport([{ eventId: 'today', contest: 'VP', date: '2026-10-11', problems: [{ problemId: 'A', status: 'AC', independent: true, competencies: ['graph-modeling'] }] }])
39
+ assert.equal(store.focus().focus[0].baselineCount, 1)
40
+ assert.equal(store.focus().daysRemaining, 6)
41
+ } finally { rmSync(dir, { recursive: true, force: true }) }
42
+ })
43
+
44
+ test('event measurements are explicit finite facts and sync preserves coach annotations', () => {
45
+ const { dir, store } = fixture()
46
+ try {
47
+ const event = { eventId: 'measured', contest: 'VP', date: '2026-10-10', problems: [{ problemId: 'A', status: 'AC' }] }
48
+ const measurements = [{ competency: 'contest-reading', metric: 'missed-reading', value: 0, passed: true, independent: true }]
49
+ assert.equal(store.vpImport([{ ...event, measurements }]).ok, true)
50
+ const metric = store.evidence().evidence.find(fact => fact.metric === 'missed-reading')
51
+ assert.ok(metric)
52
+ assert.equal(metric.signal, 'baseline')
53
+ assert.equal(metric.value, 0)
54
+ const invalid = store.vpImport([{ ...event, measurements: [{ ...measurements[0], value: 'Infinity' }] }])
55
+ assert.equal(invalid.ok, false)
56
+ store.vpImport([{ ...event, measurements, problems: [{ ...event.problems[0], independent: true, competencies: ['graph-modeling'] }] }])
57
+ const before = store.evidence().evidence.map(fact => fact.id)
58
+ store.vpImport([event], { source: 'sync' })
59
+ assert.deepEqual(store.evidence().evidence.map(fact => fact.id), before)
60
+ store.vpImport([{ ...event, problems: [{ problemId: 'A', status: 'WA' }] }], { source: 'sync' })
61
+ assert.equal(store.evidence().evidence.find(fact => fact.competency === 'graph-modeling').signal, 'observation')
62
+ assert.deepEqual(store.loadVp().events[0].measurements, measurements)
63
+ } finally { rmSync(dir, { recursive: true, force: true }) }
64
+ })
65
+
66
+ test('successful VP observations never become repair priority or reported problems', () => {
67
+ const { dir, store } = fixture({ now: () => new Date('2026-10-11T12:00:00Z') })
68
+ try {
69
+ store.target('set', { expectedRevision: 0, contest: '训练目标', date: '2026-10-17', result: '稳定发挥', weeklyHours: 25,
70
+ priorities: [{ id: 'dp-modeling', weight: 10 }, { id: 'graph-modeling', weight: 10 }] })
71
+ store.vpImport(Array.from({ length: 5 }, (_, i) => ({ eventId: `dp-success-${i}`, contest: 'VP', date: '2026-10-10',
72
+ problems: [{ problemId: 'A', status: 'AC', independent: true, competencies: ['dp-modeling'] }] })))
73
+ const dp = store.focus().focus.find(item => item.id === 'dp-modeling')
74
+ assert.equal(dp.gapCount, 0)
75
+ assert.equal(dp.baselineCount, 5)
76
+ assert.doesNotMatch(dp.whyNow, /5 次相关问题|优先修复/)
77
+ assert.equal(dp.score, 10)
78
+ } finally { rmSync(dir, { recursive: true, force: true }) }
79
+ })
80
+
81
+ test('hypotheses stay observations and confirmation requires cited facts from the same VP', () => {
82
+ const { dir, store } = fixture({ now: () => new Date('2026-10-11T12:00:00Z') })
83
+ try {
84
+ store.vpImport([{ eventId: 'reason-1', contest: 'VP', date: '2026-10-10',
85
+ problems: [{ problemId: 'B', status: 'WA', competencies: ['graph-modeling'] }] }])
86
+ const fact = store.evidence().evidence[0]
87
+ assert.equal(fact.signal, 'observation')
88
+ const hypothesis = { competency: 'graph-modeling', status: 'hypothesis', cause: '可能把边建反了,需要查代码确认', evidenceRefs: [fact.id] }
89
+ assert.equal(store.postmortem({ eventId: 'reason-1', findings: [hypothesis] }).ok, true)
90
+ assert.equal(store.evidence().evidence.some(entry => entry.signal === 'gap'), false)
91
+ const forged = store.postmortem({ eventId: 'reason-1', findings: [{ ...hypothesis, status: 'confirmed', evidenceRefs: ['fabricated'] }] })
92
+ assert.equal(forged.ok, false)
93
+ assert.equal(store.postmortem({ eventId: 'reason-1', findings: [{ ...hypothesis, status: 'confirmed' }] }).ok, true)
94
+ const gap = store.evidence().evidence.find(entry => entry.signal === 'gap')
95
+ assert.ok(gap)
96
+ assert.match(gap.summary, /边建反/)
97
+ store.vpImport([{ eventId: 'reason-1', contest: 'VP', date: '2026-10-10',
98
+ problems: [{ problemId: 'B', status: 'AC', independent: true, competencies: ['graph-modeling'] }] }])
99
+ assert.equal(store.evidence().evidence.some(entry => entry.signal === 'gap'), false, 'changed facts invalidate old confirmation')
100
+ } finally { rmSync(dir, { recursive: true, force: true }) }
101
+ })
102
+
103
+ test('synced unlabelled facts can be cited without fabricating an algorithm tag', () => {
104
+ const { dir, store } = fixture()
105
+ try {
106
+ store.vpImport([{ eventId: 'unlabelled', contest: 'VP', date: '2026-10-10', problems: [{ problemId: 'A', status: 'WA' }] }])
107
+ const fact = store.evidence().evidence[0]
108
+ assert.ok(fact)
109
+ assert.equal(fact.competency, '')
110
+ assert.equal(store.postmortem({ eventId: 'unlabelled', findings: [{ competency: 'implementation-stability', status: 'confirmed',
111
+ cause: '检查代码后确认多组数据没有清空数组', evidenceRefs: [fact.id] }] }).ok, true)
112
+ } finally { rmSync(dir, { recursive: true, force: true }) }
113
+ })
114
+
115
+ test('confirmed gaps are counted once per recent event and old edits do not refresh them', () => {
116
+ const { dir, store } = fixture({ now: () => new Date('2026-10-11T12:00:00Z') })
117
+ try {
118
+ store.target('set', { expectedRevision: 0, contest: '目标', date: '2026-10-17', result: '稳定发挥', weeklyHours: 25,
119
+ priorities: [{ id: 'graph-modeling', weight: 10 }, { id: 'dp-modeling', weight: 10 }] })
120
+ for (const [id, day] of [['recent', '2026-10-10'], ['old', '2026-08-01']]) {
121
+ store.vpImport([{ eventId: id, contest: 'VP', date: day, problems: ['A', 'B'].map(problemId => ({ problemId, status: 'WA', competencies: ['graph-modeling'] })) }])
122
+ const refs = store.evidence().evidence.filter(entry => entry.eventId === id && entry.source === 'vp').map(entry => entry.id)
123
+ assert.equal(store.postmortem({ eventId: id, date: '2026-10-11', findings: [{ competency: 'graph-modeling', status: 'confirmed',
124
+ cause: '核对题面与代码确认有向关系建反', evidenceRefs: refs }] }).ok, true)
125
+ }
126
+ const graph = store.focus().focus[0]
127
+ assert.equal(graph.id, 'graph-modeling')
128
+ assert.equal(graph.gapCount, 1)
129
+ assert.equal(graph.score, 20)
130
+ } finally { rmSync(dir, { recursive: true, force: true }) }
131
+ })
132
+
133
+ test('target profiles and exact node scopes retain sources without claiming an exam syllabus', () => {
134
+ const { dir, store } = fixture()
135
+ try {
136
+ writeFileSync(join(dir, 'NODE_SCOPES.yaml'), 'version: 1\nnodes:\n - id: 最短路\n title: 最短路识别\n sources: [{title: 原始资料, url: "https://oi-wiki.org/graph/shortest-path/"}]\n appliesTo: [路径代价]\n notFor: [负权直接套Dijkstra]\n')
137
+ writeFileSync(join(dir, 'CONTEST_PROFILES.yaml'), 'version: 1\nprofiles:\n - id: regional-sample\n title: 区域赛样本\n status: provisional\n scope: 样本范围\n sources: []\n competencyIds: [graph-modeling]\n selectionGuidance: [先诊断]\n limitations: [不是西安预测]\n')
138
+ const scoped = createStrategyStore({ dataDir: dir, competenciesPath: join(process.cwd(), 'assets', 'knowledge', 'COMPETENCIES.yaml'),
139
+ nodeScopesPath: join(dir, 'NODE_SCOPES.yaml'), contestProfilesPath: join(dir, 'CONTEST_PROFILES.yaml') })
140
+ assert.equal(scoped.scope('最短路').id, '最短路')
141
+ assert.equal(scoped.scope('最短路').sources.length, 1)
142
+ const saved = scoped.target('set', { expectedRevision: 0, contest: '西安', date: '2026-10-17', result: '稳定发挥', weeklyHours: 25,
143
+ profileId: 'regional-sample', priorities: [{ id: 'graph-modeling', weight: 10 }] })
144
+ assert.equal(saved.ok, true)
145
+ assert.equal(saved.profileId, 'regional-sample')
146
+ assert.equal(scoped.target('profiles').availableProfiles[0].status, 'provisional')
147
+ assert.match(scoped.focus().profile.limitations[0], /不是西安预测/)
148
+ } finally { rmSync(dir, { recursive: true, force: true }) }
149
+ })
150
+
17
151
  test('target is explicit and revision protected', () => {
18
152
  const { dir, store } = fixture()
19
153
  try {
@@ -66,7 +200,7 @@ test('first-use focus has a complete refusal shape and targets reject unknown ca
66
200
  const { dir, store } = fixture()
67
201
  try {
68
202
  const empty = store.focus()
69
- assert.deepEqual(Object.keys(empty).sort(), ['contest', 'deadline', 'evidenceEvents', 'focus', 'ok', 'postmortems', 'postponed', 'reject'])
203
+ assert.deepEqual(Object.keys(empty).sort(), ['contest', 'daysRemaining', 'deadline', 'evidenceEvents', 'focus', 'ok', 'postmortems', 'postponed', 'profile', 'reject'])
70
204
  const rejected = store.target('set', {
71
205
  expectedRevision: 0, contest: '西安区域赛', date: '2026-10-18', result: '区域赛金牌',
72
206
  weeklyHours: 35, priorities: [{ id: 'invented-card', weight: 100 }],
@@ -75,19 +209,50 @@ test('first-use focus has a complete refusal shape and targets reject unknown ca
75
209
  } finally { rmSync(dir, { recursive: true, force: true }) }
76
210
  })
77
211
 
78
- test('target route rejects an unrelated progress node but allows a course-bound route', () => {
212
+ test('target route allows a fourth-ranked target capability without imposing the focus ranking', () => {
213
+ const { dir, store } = fixture()
214
+ try {
215
+ assert.equal(store.target('set', {
216
+ expectedRevision: 0, contest: '西安区域赛', date: '2026-10-18', result: '区域赛金牌',
217
+ weeklyHours: 35, priorities: [
218
+ { id: 'contest-reading', weight: 100 },
219
+ { id: 'contest-decision', weight: 90 },
220
+ { id: 'dp-modeling', weight: 80 },
221
+ { id: 'graph-modeling', weight: 10 },
222
+ ],
223
+ }).ok, true)
224
+ assert.deepEqual(store.focus().postponed, ['graph-modeling'])
225
+ const route = store.route('最短路', { purpose: 'progress', courseBound: false })
226
+ assert.equal(route.ok, true)
227
+ assert.equal(route.targetAligned, true)
228
+ assert.deepEqual(route.competencyIds, ['graph-modeling'])
229
+ } finally { rmSync(dir, { recursive: true, force: true }) }
230
+ })
231
+
232
+ test('target route reports alignment without rejecting the coach choice', () => {
79
233
  const { dir, store } = fixture()
80
234
  try {
235
+ assert.equal(store.route('树状数组').targetAligned, false, 'no target is not an alignment claim')
81
236
  store.target('set', {
82
237
  expectedRevision: 0, contest: '西安区域赛', date: '2026-10-18', result: '区域赛金牌',
83
238
  weeklyHours: 35, priorities: [{ id: 'dp-modeling', weight: 100 }],
84
239
  })
85
- const unrelated = store.route('树状数组', { purpose: 'progress', courseBound: false })
86
- assert.equal(unrelated.ok, false)
87
- assert.match(unrelated.reject, /dp-modeling|当前重点/)
88
- const courseBound = store.route('树状数组', { purpose: 'progress', courseBound: true })
89
- assert.equal(courseBound.ok, true)
90
- assert.equal(courseBound.courseBound, true)
240
+ for (const options of [
241
+ { purpose: 'progress', courseBound: false },
242
+ { purpose: 'progress', courseBound: true },
243
+ { purpose: 'remediation', courseBound: false },
244
+ { purpose: 'review', courseBound: false },
245
+ ]) {
246
+ const unrelated = store.route('树状数组', options)
247
+ assert.equal(unrelated.ok, true)
248
+ assert.equal(unrelated.reject, '')
249
+ assert.equal(unrelated.targetAligned, false)
250
+ assert.deepEqual(unrelated.competencyIds, [])
251
+ assert.equal(unrelated.courseBound, options.courseBound)
252
+ }
253
+ const alignedReview = store.route('动态规划基础', { purpose: 'review', courseBound: true })
254
+ assert.equal(alignedReview.targetAligned, true)
255
+ assert.deepEqual(alignedReview.competencyIds, ['dp-modeling'])
91
256
  } finally { rmSync(dir, { recursive: true, force: true }) }
92
257
  })
93
258
 
@@ -116,8 +281,8 @@ test('strategy exposes stable competency evidence for curriculum gates', () => {
116
281
  problems: [{ problemId: 'C', status: 'WA', competencies: ['contest-decision'] }] }])
117
282
  const result = store.evidence()
118
283
  assert.equal(result.ok, true)
119
- assert.equal(result.evidence[0].id, 'vp:vp-evidence:C:contest-decision')
120
- assert.equal(result.evidence[0].signal, 'gap')
284
+ assert.match(result.evidence[0].id, /^vp:vp-evidence:C:contest-decision:[a-f0-9]{64}$/)
285
+ assert.equal(result.evidence[0].signal, 'observation')
121
286
  assert.equal(result.evidence[0].competency, 'contest-decision')
122
287
  } finally { rmSync(dir, { recursive: true, force: true }) }
123
288
  })
@@ -179,5 +344,8 @@ test('corrupt stored strategy data returns a structured refusal', () => {
179
344
  const result = store.target('read')
180
345
  assert.equal(result.ok, false)
181
346
  assert.match(result.reject, /TARGET.yaml/)
347
+ const route = store.route('最短路')
348
+ assert.equal(route.ok, false)
349
+ assert.match(route.reject, /TARGET.yaml/)
182
350
  } finally { rmSync(dir, { recursive: true, force: true }) }
183
351
  })