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.
@@ -0,0 +1,191 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { mkdtempSync, readFileSync, rmdirSync, unlinkSync, writeFileSync } from 'node:fs'
4
+ import { join } from 'node:path'
5
+ import { tmpdir } from 'node:os'
6
+ import { createTeachingNotes } from '../lib/teaching.js'
7
+
8
+ const AT = '2026-10-11T10:00:00.000Z'
9
+ const EXEC = { agent: { session: { id: 'session-1' } } }
10
+ const QUOTE = '状态不需要保存选择顺序,只需要保存剩余容量。'
11
+ const userEvent = (overrides = {}) => ({
12
+ type: 'user/message', seq: 7,
13
+ data: { id: 'message-7', role: 'user', source: { kind: 'user' }, content: [{ type: 'text', text: QUOTE }] },
14
+ ...overrides,
15
+ })
16
+
17
+ function fixture(t) {
18
+ const root = mkdtempSync(join(tmpdir(), 'acmer-coach-teaching-'))
19
+ const path = join(root, 'PROGRESS.json')
20
+ const initial = {
21
+ version: 1, cursor: 'dp', updated: '2026-10-10',
22
+ nodes: { dp: { status: 'verified', taught: '2026-10-09', passes: [{ date: '2026-10-10' }] } },
23
+ pending: { node: 'graph', date: '2026-10-11', problems: ['fixture-problem'] },
24
+ pendingActions: { dp: { node: 'dp', deliverable: '写出状态含义' } },
25
+ verified_problems: { 'fixture-problem': { source: 'fixture' } },
26
+ }
27
+ writeFileSync(path, JSON.stringify(initial))
28
+ t.after(() => { unlinkSync(path); rmdirSync(root) })
29
+ const loadProgress = () => JSON.parse(readFileSync(path, 'utf8'))
30
+ const saveProgress = (p) => { writeFileSync(path, JSON.stringify(p)); return p }
31
+ const dependencies = {
32
+ loadProgress, saveProgress,
33
+ loadMap: () => ({ byId: new Map([['dp', { id: 'dp', name: '动态规划基础' }], ['graph', { id: 'graph', name: '最短路' }]]) }),
34
+ getSessionQuery: () => undefined,
35
+ now: () => AT,
36
+ }
37
+ return { initial, loadProgress, saveProgress, dependencies, create: () => createTeachingNotes(dependencies) }
38
+ }
39
+
40
+ test('续课笔记留在既有节点账本,重建后可读且不改变掌握与待测卷', async (t) => {
41
+ const f = fixture(t)
42
+ const result = await f.create().write({ summary: '状态含义还需检查', nextStep: '用三个物品的小例子让学生解释状态' })
43
+ assert.equal(result.ok, true)
44
+ assert.equal(result.node, 'dp')
45
+ assert.deepEqual(result.notes, [{ at: AT, summary: '状态含义还需检查', nextStep: '用三个物品的小例子让学生解释状态' }])
46
+ assert.deepEqual(f.create().read(), result)
47
+ const expected = structuredClone(f.initial)
48
+ expected.nodes.dp.lessonNotes = [{ ...result.notes[0], studentEvidence: null }]
49
+ assert.deepEqual(f.loadProgress(), expected)
50
+ })
51
+
52
+ test('无效知识点、空白和超长笔记不能写入账本', async (t) => {
53
+ const f = fixture(t)
54
+ const notes = f.create()
55
+ const valid = { summary: '仍需辨析状态', nextStep: '解释一个反例' }
56
+ for (const change of [
57
+ { node: 'unknown' }, { node: 123 }, { summary: ' ' }, { summary: 'x'.repeat(1001) },
58
+ { nextStep: '' }, { nextStep: 'x'.repeat(501) }, { studentQuote: ' ' },
59
+ { studentQuote: 'x'.repeat(1501) },
60
+ ]) {
61
+ const result = await notes.write({ ...valid, ...change })
62
+ assert.equal(result.ok, false, JSON.stringify(change))
63
+ assert.deepEqual(result.notes, [])
64
+ assert.equal(result.total, 0)
65
+ assert.deepEqual(f.loadProgress(), f.initial)
66
+ }
67
+ for (const limit of [0, -1, 21, 1.5, '5']) assert.equal(notes.read({ limit }).ok, false)
68
+ })
69
+
70
+ test('学生原话通过当前原生会话核对并保存稳定出处', async (t) => {
71
+ const f = fixture(t)
72
+ let queried
73
+ f.dependencies.getSessionQuery = () => ({ readSession: async (id) => {
74
+ queried = id
75
+ return { session: { id }, events: [userEvent()] }
76
+ } })
77
+ const result = await f.create().write({ summary: '能区分容量和顺序,尚未检查代码', nextStep: '独立实现转移', studentQuote: QUOTE }, EXEC)
78
+ assert.equal(queried, 'session-1')
79
+ assert.equal(result.ok, true)
80
+ assert.deepEqual(result.notes[0].studentEvidence, { sessionId: 'session-1', seq: 7, messageId: 'message-7', quote: QUOTE })
81
+ assert.equal(f.loadProgress().nodes.dp.status, 'verified')
82
+ })
83
+
84
+ test('伪造原话、插件消息、跨会话内容或缺失出处不能成为学生依据', async (t) => {
85
+ const invalidEvent = [
86
+ userEvent({ data: { ...userEvent().data, content: [{ type: 'text', text: '别的答复' }] } }),
87
+ userEvent({ data: { ...userEvent().data, source: { kind: 'plugin:coach' } } }),
88
+ userEvent({ data: { ...userEvent().data, role: 'assistant' } }),
89
+ userEvent({ seq: -1 }), userEvent({ seq: undefined }),
90
+ userEvent({ data: { ...userEvent().data, id: '' } }),
91
+ userEvent({ type: 'compaction/message' }),
92
+ userEvent({ data: { ...userEvent().data, content: [{ type: 'image', text: QUOTE }] } }),
93
+ ]
94
+ for (const event of invalidEvent) {
95
+ const f = fixture(t)
96
+ f.dependencies.getSessionQuery = () => ({ readSession: async () => ({ session: { id: 'session-1' }, events: [event] }) })
97
+ const result = await f.create().write({ summary: '判断', nextStep: '继续', studentQuote: QUOTE }, EXEC)
98
+ assert.equal(result.ok, false)
99
+ assert.deepEqual(f.loadProgress(), f.initial)
100
+ }
101
+ const f = fixture(t)
102
+ const params = { summary: '判断', nextStep: '继续', studentQuote: QUOTE }
103
+ assert.equal((await f.create().write(params, EXEC)).ok, false, '无会话查询服务必须拒绝')
104
+ f.dependencies.getSessionQuery = () => ({ readSession: async () => ({ session: { id: 'another-session' }, events: [userEvent()] }) })
105
+ assert.equal((await f.create().write(params, EXEC)).ok, false)
106
+ assert.equal((await f.create().write(params)).ok, false, '无当前调用会话必须拒绝')
107
+ f.dependencies.getSessionQuery = () => ({ readSession: async () => { throw new Error('unavailable') } })
108
+ assert.equal((await f.create().write(params, EXEC)).ok, false)
109
+ assert.deepEqual(f.loadProgress(), f.initial)
110
+ })
111
+
112
+ test('没有游标时找最近一次续课,默认只读最近五条并保留全部历史', async (t) => {
113
+ const f = fixture(t)
114
+ const notes = f.create()
115
+ for (let i = 1; i <= 6; i++) await notes.write({ summary: `第${i}次讲解`, nextStep: '继续解释转移' })
116
+ assert.equal(notes.read().total, 6)
117
+ assert.equal(notes.read().notes.length, 5)
118
+ assert.equal(notes.read().notes[0].summary, '第2次讲解')
119
+ f.dependencies.now = () => '2026-10-11T11:00:00.000Z'
120
+ await f.create().write({ node: 'graph', summary: '图上松弛还要澄清', nextStep: '比较两条路径' })
121
+ const progress = f.loadProgress()
122
+ progress.cursor = ''
123
+ progress.pendingActions = {}
124
+ f.saveProgress(progress)
125
+ assert.equal(f.create().read().node, 'graph')
126
+ assert.equal(f.create().read({ node: 'dp', limit: 20 }).notes.length, 6)
127
+ })
128
+
129
+ test('续课上下文短且标明教练判断,核对原话不等于证明掌握', async (t) => {
130
+ const f = fixture(t)
131
+ const notes = f.create()
132
+ assert.equal(notes.context(f.loadProgress()), '')
133
+ await notes.write({ summary: '状态含义还需辨析'.repeat(100), nextStep: '用小例子解释状态'.repeat(60) })
134
+ const context = notes.context(f.loadProgress())
135
+ assert.ok(context.length <= 700)
136
+ assert.match(context, /教练判断/)
137
+ assert.match(context, /下次接续/)
138
+ assert.equal(notes.context(f.loadProgress(), 'graph'), '')
139
+ f.dependencies.getSessionQuery = () => ({ readSession: async () => ({ session: { id: 'session-1' }, events: [userEvent()] }) })
140
+ await f.create().write({ summary: '对状态有一个合理解释', nextStep: '独立写出转移', studentQuote: QUOTE }, EXEC)
141
+ const grounded = f.create().context(f.loadProgress())
142
+ assert.match(grounded, /原话已核对/)
143
+ assert.match(grounded, /不代表掌握/)
144
+ assert.match(grounded, /独立写出转移/)
145
+ })
146
+
147
+ test('原话查询期间其他工具保存的进度不会被旧快照覆盖', async (t) => {
148
+ const f = fixture(t)
149
+ let release
150
+ f.dependencies.getSessionQuery = () => ({ readSession: () => new Promise((resolve) => { release = resolve }) })
151
+ const writing = f.create().write({ summary: '状态解释待验证', nextStep: '写出转移', studentQuote: QUOTE }, EXEC)
152
+ const updated = f.loadProgress()
153
+ updated.cursor = 'graph'
154
+ updated.nodes.graph = { status: 'studying', checks: [{ outcome: 'needs-work' }] }
155
+ updated.pendingActions.dp.deliverable = '更新后的动作'
156
+ f.saveProgress(updated)
157
+ release({ session: { id: 'session-1' }, events: [userEvent()] })
158
+ assert.equal((await writing).ok, true)
159
+ const saved = f.loadProgress()
160
+ assert.equal(saved.cursor, 'graph')
161
+ assert.deepEqual(saved.nodes.graph, updated.nodes.graph)
162
+ assert.deepEqual(saved.pendingActions, updated.pendingActions)
163
+ assert.deepEqual(saved.pending, updated.pending)
164
+ assert.deepEqual(saved.verified_problems, updated.verified_problems)
165
+ assert.equal(saved.nodes.dp.lessonNotes.length, 1)
166
+ })
167
+
168
+ test('续课优先最近未完动作的笔记,显式读取和默认写入不受选择影响', async (t) => {
169
+ const f = fixture(t)
170
+ await f.create().write({ summary: '游标节点的旧笔记', nextStep: '继续状态解释' })
171
+ const progress = f.loadProgress()
172
+ progress.pendingActions.graph = { node: 'graph', deliverable: '解释松弛' }
173
+ f.saveProgress(progress)
174
+ f.dependencies.now = () => '2026-10-11T11:00:00.000Z'
175
+ await f.create().write({ node: 'graph', summary: '刚讲到路径比较', nextStep: '继续比较两条路径' })
176
+ const notes = f.create()
177
+ assert.equal(notes.read().node, 'graph')
178
+ assert.match(notes.context(f.loadProgress()), /刚讲到路径比较/)
179
+ assert.equal(notes.read({ node: 'dp' }).node, 'dp')
180
+ assert.match(notes.context(f.loadProgress(), 'dp'), /游标节点的旧笔记/)
181
+ f.dependencies.now = () => '2026-10-11T12:00:00.000Z'
182
+ assert.equal((await f.create().write({ summary: '仍写入当前游标', nextStep: '继续游标节点' })).node, 'dp')
183
+ assert.equal(f.loadProgress().cursor, 'dp')
184
+ assert.deepEqual(f.loadProgress().pendingActions, progress.pendingActions)
185
+ assert.equal(f.create().read().node, 'dp', '多个未完动作按笔记时间选最近')
186
+ const finished = f.loadProgress()
187
+ delete finished.pendingActions.graph
188
+ delete finished.pendingActions.dp
189
+ f.saveProgress(finished)
190
+ assert.equal(f.create().read().node, 'dp', '没有未完动作回到当前游标')
191
+ })
@@ -7,6 +7,7 @@ import { createVpClient } from '../lib/vp-client.js'
7
7
  const root = mkdtempSync(join(tmpdir(), 'acmer-coach-vp-'))
8
8
  let account = { id: 1, handle: 'A', display_name: 'Coach A' }
9
9
  let mismatch = false
10
+ let nextPage = false
10
11
  const fetchImpl = async (url, options = {}) => {
11
12
  const path = new URL(url).pathname
12
13
  const body = options.body ? JSON.parse(options.body) : {}
@@ -19,8 +20,8 @@ const fetchImpl = async (url, options = {}) => {
19
20
  if (path.endsWith('/api/coach/contests')) return json({ account, contests: [{ id: 7, title: 'VP 7', status: 'ended' }] })
20
21
  if (path.endsWith('/api/coach/sync')) return json({
21
22
  account: mismatch ? { id: 1, handle: 'A' } : account,
22
- submissions: [{ id: 9, contest_id: 7, verdict: 'AC' }], events: [], contests: [],
23
- next_cursor: { submission_id: 9, event_id: 0 }, has_more: false,
23
+ submissions: [{ id: nextPage ? 10 : 9, contest_id: 7, verdict: nextPage ? 'WA' : 'AC' }], events: [], contests: [],
24
+ next_cursor: { submission_id: nextPage ? 10 : 9, event_id: 0 }, has_more: !nextPage,
24
25
  })
25
26
  if (path.endsWith('/api/coach/replay/7')) return json({ account, visualization: { team_events: [] } })
26
27
  throw new Error(`unexpected ${url}`)
@@ -34,7 +35,16 @@ try {
34
35
  await client.finish({ code: 'A' })
35
36
  const synced = await client.sync()
36
37
  assert.equal(synced.account.id, 1)
38
+ assert.equal(synced.hasMore, true, 'first page must expose continuation')
39
+ assert.deepEqual(synced.pageCounts, { submissions: 1, events: 0, contests: 0 })
37
40
  assert.equal(JSON.parse(readFileSync(join(root, 'vp-accounts', '1', 'SYNC.json'))).account.id, 1)
41
+ nextPage = true
42
+ const secondPage = await client.sync()
43
+ assert.equal(secondPage.hasMore, false, 'last page must expose completion')
44
+ assert.deepEqual(secondPage.pageCounts, { submissions: 1, events: 0, contests: 0 }, 'page counts must not include old cached rows')
45
+ assert.deepEqual(secondPage.submissions.map(item => item.id), [9, 10], 'incremental pages must retain earlier accepted runs')
46
+ assert.deepEqual(JSON.parse(readFileSync(join(root, 'vp-accounts', '1', 'SYNC.json'))).submissions.map(item => item.id), [9, 10])
47
+ nextPage = false
38
48
  await client.finish({ code: 'B' })
39
49
  assert.equal((await client.status()).account.id, 2)
40
50
  assert.equal((await client.contests()).contests[0].id, 7)
@@ -0,0 +1,122 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+
7
+ function shape(schema, value) {
8
+ if (schema.type === 'object') {
9
+ assert.ok(value && typeof value === 'object' && !Array.isArray(value))
10
+ if (schema.additionalProperties === false) for (const key of Object.keys(value)) assert.ok(key in schema.properties, `unexpected field ${key}`)
11
+ for (const [key, field] of Object.entries(schema.properties ?? {})) {
12
+ if (field.required === true || (Array.isArray(schema.required) && schema.required.includes(key))) assert.ok(key in value, `missing field ${key}`)
13
+ if (key in value) shape(field, value[key])
14
+ }
15
+ } else if (schema.type === 'array') {
16
+ assert.ok(Array.isArray(value))
17
+ if (schema.items) value.forEach(item => shape(schema.items, item))
18
+ } else if (schema.type === 'integer') assert.ok(Number.isInteger(value))
19
+ else assert.equal(typeof value, schema.type)
20
+ }
21
+
22
+ async function fixture(run) {
23
+ const dir = mkdtempSync(join(tmpdir(), 'coach-vp-sync-'))
24
+ const previous = { data: process.env.COACH_DATA_DIR, db: process.env.COACH_DB, fetch: globalThis.fetch }
25
+ const account = { id: 1, handle: 'mock-account', display_name: 'Mock account' }
26
+ let page = 0
27
+ let mode = 'normal'
28
+ const calls = []
29
+ try {
30
+ writeFileSync(join(dir, 'MAP.yaml'), 'meta: {version: 1}\nnodes: []\n')
31
+ process.env.COACH_DATA_DIR = dir
32
+ process.env.COACH_DB = join(dir, 'unused.db')
33
+ globalThis.fetch = async url => {
34
+ const parsed = new URL(url)
35
+ if (parsed.pathname === '/api/coach/auth/exchange') return json({ access_token: 'test-token', user: account })
36
+ if (parsed.pathname !== '/api/coach/sync') throw new Error(`unexpected mock route ${parsed.pathname}`)
37
+ if (mode === 'error') return { ok: false, status: 503, json: async () => ({ detail: 'temporary mock failure' }) }
38
+ calls.push(Number(parsed.searchParams.get('submission_cursor')))
39
+ const submissionId = mode === 'duplicate' ? page : ++page
40
+ const contest = { id: mode === 'invalid' ? 8 : 7, title: 'Mock VP', start_time: mode === 'invalid' ? '2026-02-30T00:00:00Z' : mode === 'naive-utc' ? '2026-10-10 00:00:00' : '2026-10-10T00:00:00Z', duration_min: 300 }
41
+ return json({ account, contests: [contest], events: [],
42
+ submissions: [{ id: submissionId, contest_id: contest.id, problem_id: 'A', verdict: 'AC', submitted_at: mode === 'naive-utc' ? '2026-10-10 00:10:00' : '2026-10-10T00:10:00Z' }],
43
+ next_cursor: { submission_id: submissionId, event_id: 0 }, has_more: page === 1 })
44
+ }
45
+ const { apply } = await import(`../index.js?vp-sync-${dir.replace(/\\/g, '/')}`)
46
+ const registered = new Map()
47
+ apply({ tools: { register: tool => registered.set(tool.name, tool) }, inject() {}, on() {} })
48
+ const call = async (name, args = {}) => {
49
+ const tool = registered.get(name)
50
+ const result = await tool.execute(args)
51
+ shape(tool.output.schema, result)
52
+ return result
53
+ }
54
+ assert.equal((await call('coach_vp_connect', { action: 'finish', code: 'test-code' })).ok, true)
55
+ await run({ call, calls, mode: value => { mode = value } })
56
+ } finally {
57
+ globalThis.fetch = previous.fetch
58
+ for (const [key, value] of [['COACH_DATA_DIR', previous.data], ['COACH_DB', previous.db]]) {
59
+ if (value === undefined) delete process.env[key]
60
+ else process.env[key] = value
61
+ }
62
+ rmSync(dir, { recursive: true, force: true })
63
+ }
64
+ }
65
+
66
+ function json(value) { return { ok: true, json: async () => value } }
67
+
68
+ test('public VP sync exposes two-page continuation and distinguishes page rows from cumulative cache', async () => {
69
+ await fixture(async ({ call, calls, mode }) => {
70
+ const first = await call('coach_vp_sync')
71
+ assert.equal(first.ok, true, first.reject)
72
+ assert.equal(first.hasMore, true)
73
+ assert.deepEqual(first.pageCounts, { submissions: 1, events: 0, contests: 1 })
74
+ const second = await call('coach_vp_sync')
75
+ assert.equal(second.ok, true, second.reject)
76
+ assert.equal(second.hasMore, false)
77
+ assert.deepEqual(second.pageCounts, { submissions: 1, events: 0, contests: 1 })
78
+ assert.equal(second.submissions.length, 2)
79
+ assert.deepEqual(calls, [0, 1])
80
+ assert.equal(second.nextCursor.submission_id, 2)
81
+ assert.match(second.text, /缓存累计/)
82
+ assert.doesNotMatch(second.text, /新增训练证据/)
83
+ mode('duplicate')
84
+ const repeated = await call('coach_vp_sync')
85
+ assert.equal(repeated.ok, true, repeated.reject)
86
+ assert.equal(repeated.submissions.length, 2)
87
+ assert.deepEqual(repeated.pageCounts, { submissions: 1, events: 0, contests: 1 })
88
+ assert.match(repeated.text, /缓存累计/)
89
+ assert.doesNotMatch(repeated.text, /新增训练证据/)
90
+ })
91
+ })
92
+
93
+ test('public VP sync propagates training import refusal and network failure with complete output shapes', async () => {
94
+ await fixture(async ({ call, mode }) => {
95
+ mode('invalid')
96
+ const rejected = await call('coach_vp_sync')
97
+ assert.equal(rejected.ok, false, 'invalid imported date must not be reported as successful sync')
98
+ assert.match(rejected.reject, /VP 导入被拒绝/)
99
+ assert.equal(rejected.imported, 0)
100
+ mode('error')
101
+ const failed = await call('coach_vp_sync')
102
+ assert.equal(failed.ok, false)
103
+ assert.match(failed.reject, /503/)
104
+ assert.equal(failed.hasMore, false)
105
+ assert.deepEqual(failed.pageCounts, { submissions: 0, events: 0, contests: 0 })
106
+ assert.deepEqual(failed.nextCursor, { submission_id: 0, event_id: 0 })
107
+ })
108
+ })
109
+
110
+ test('platform UTC timestamps without a timezone become valid ISO evidence', async () => {
111
+ await fixture(async ({ call, mode }) => {
112
+ mode('naive-utc')
113
+ const synced = await call('coach_vp_sync')
114
+ assert.equal(synced.ok, true, synced.reject)
115
+ const facts = await call('coach_curriculum', { action: 'assess', evidenceEventIds: ['vp:1:7'] })
116
+ assert.equal(facts.evidence[0].occurredAt, '2026-10-10T00:10:00.000Z')
117
+ assert.equal(facts.evidence[0].minutes, 10)
118
+ const platformStart = new Date('2026-10-10T00:00:00Z')
119
+ const localDate = `${platformStart.getFullYear()}-${String(platformStart.getMonth() + 1).padStart(2, '0')}-${String(platformStart.getDate()).padStart(2, '0')}`
120
+ assert.equal(facts.evidence[0].date, localDate)
121
+ })
122
+ })
@@ -1,11 +1,27 @@
1
1
  import test from 'node:test'
2
2
  import assert from 'node:assert/strict'
3
- import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync } from 'node:fs'
4
4
  import { join } from 'node:path'
5
5
  import { tmpdir } from 'node:os'
6
6
 
7
- test('startup migrates legacy personal files out of an active VP account', async () => {
7
+ const target = (contest) => `version: 1\nrevision: 1\nupdated: 2026-10-11T00:51:42.901Z\ncontest: ${contest}\ndate: 2026-10-17\nresult: 保铜争银\nteamMode: team\nweeklyHours: 25\nconstraints: {}\npriorities: []\n`
8
+ let startupCount = 0
9
+ async function startup(root) {
10
+ const previous = process.env.COACH_DATA_DIR
11
+ const previousDb = process.env.COACH_DB
12
+ process.env.COACH_DATA_DIR = root
13
+ process.env.COACH_DB = join(root, 'unused.db')
14
+ try { await import(`../index.js?workspace-migration=${Date.now()}-${startupCount++}`) }
15
+ finally {
16
+ if (previous === undefined) delete process.env.COACH_DATA_DIR
17
+ else process.env.COACH_DATA_DIR = previous
18
+ if (previousDb === undefined) delete process.env.COACH_DB
19
+ else process.env.COACH_DB = previousDb
20
+ }
21
+ }
22
+ function fixture(t) {
8
23
  const root = mkdtempSync(join(tmpdir(), 'acmer-coach-workspace-'))
24
+ t.after(() => rmSync(root, { recursive: true, force: true }))
9
25
  const accountDir = join(root, 'vp-accounts', '6')
10
26
  mkdirSync(accountDir, { recursive: true })
11
27
  writeFileSync(join(root, 'VP_ACCOUNTS.json'), JSON.stringify({
@@ -13,22 +29,78 @@ test('startup migrates legacy personal files out of an active VP account', async
13
29
  activeAccountId: 6,
14
30
  accounts: { 6: { account: { id: 6, handle: 'Mianiii', display_name: 'Mianiii' }, baseUrl: 'https://example.test' } },
15
31
  }))
16
- writeFileSync(join(root, 'TARGET.yaml'), 'version: 1\nrevision: 1\nupdated: 2026-10-10T20:55:57.385Z\ncontest: 旧目标\ndate: 2026-10-17\nresult: 保铜争银\nteamMode: team\nweeklyHours: 25\nconstraints: {}\npriorities: []\n')
17
- writeFileSync(join(accountDir, 'TARGET.yaml'), 'version: 1\nrevision: 1\nupdated: 2026-10-11T00:51:42.901Z\ncontest: 西安区域赛\ndate: 2026-10-17\nresult: 保铜争银\nteamMode: team\nweeklyHours: 25\nconstraints: {}\npriorities: []\n')
18
- const oldTargetTime = new Date('2026-10-10T20:55:57.385Z')
19
- const newTargetTime = new Date('2026-10-11T00:51:42.901Z')
20
- const { utimesSync } = await import('node:fs')
21
- utimesSync(join(root, 'TARGET.yaml'), oldTargetTime, oldTargetTime)
22
- utimesSync(join(accountDir, 'TARGET.yaml'), newTargetTime, newTargetTime)
23
- process.env.COACH_DATA_DIR = root
24
- try {
25
- await import(`../index.js?workspace-migration=${Date.now()}`)
26
- assert.equal(existsSync(join(root, 'TARGET.yaml')), true)
27
- assert.equal(existsSync(join(accountDir, 'TARGET.yaml')), true)
28
- assert.equal(existsSync(join(root, 'TARGET.yaml.pre-vp-account-6')), true)
29
- assert.match((await import('node:fs')).readFileSync(join(root, 'TARGET.yaml'), 'utf8'), /contest: 西安区域赛/)
30
- } finally {
31
- delete process.env.COACH_DATA_DIR
32
- rmSync(root, { recursive: true, force: true })
33
- }
32
+ writeFileSync(join(accountDir, 'TARGET.yaml'), target('账号旧目标'))
33
+ return { root, accountDir, main: join(root, 'TARGET.yaml'), source: join(accountDir, 'TARGET.yaml'), marker: join(accountDir, 'PERSONAL_MIGRATION.json') }
34
+ }
35
+
36
+ test('startup fills missing personal files while retaining their account originals', async (t) => {
37
+ const f = fixture(t)
38
+ await startup(f.root)
39
+ assert.equal(readFileSync(f.main, 'utf8'), target('账号旧目标'))
40
+ assert.equal(readFileSync(f.source, 'utf8'), target('账号旧目标'))
41
+ assert.equal(existsSync(f.marker), true)
42
+ })
43
+
44
+ test('first migration preserves an existing global ledger even when the account mtime is newer', async (t) => {
45
+ const f = fixture(t)
46
+ writeFileSync(f.main, target('合法个人目标'))
47
+ const older = new Date('2026-10-10T12:00:00Z'), newer = new Date('2026-10-12T12:00:00Z')
48
+ utimesSync(f.main, older, older); utimesSync(f.source, newer, newer)
49
+ await startup(f.root)
50
+ assert.equal(readFileSync(f.main, 'utf8'), target('合法个人目标'))
51
+ assert.equal(readFileSync(`${f.main}.from-vp-account-6`, 'utf8'), target('账号旧目标'))
52
+ assert.equal(readFileSync(f.source, 'utf8'), target('账号旧目标'))
53
+ })
54
+
55
+ test('repeated startup never replaces edited global data after old account files change', async (t) => {
56
+ const f = fixture(t)
57
+ await startup(f.root)
58
+ const marker = existsSync(f.marker) ? readFileSync(f.marker, 'utf8') : ''
59
+ writeFileSync(f.main, target('迁移后合法修订'))
60
+ writeFileSync(f.source, target('旧账号后来变化'))
61
+ const newer = new Date('2026-10-12T12:00:00Z')
62
+ utimesSync(f.source, newer, newer)
63
+ await startup(f.root)
64
+ assert.equal(readFileSync(f.main, 'utf8'), target('迁移后合法修订'))
65
+ assert.equal(readFileSync(f.marker, 'utf8'), marker)
66
+ })
67
+
68
+ test('a newer global ledger is also finalized once rather than reconsidered on later account updates', async (t) => {
69
+ const f = fixture(t)
70
+ writeFileSync(f.main, target('已经较新的个人目标'))
71
+ const old = new Date('2026-10-10T12:00:00Z')
72
+ utimesSync(f.source, old, old)
73
+ await startup(f.root)
74
+ writeFileSync(f.source, target('再次变新的旧账号目标'))
75
+ const newer = new Date('2026-10-13T12:00:00Z')
76
+ utimesSync(f.source, newer, newer)
77
+ await startup(f.root)
78
+ assert.equal(readFileSync(f.main, 'utf8'), target('已经较新的个人目标'))
79
+ assert.equal(readFileSync(`${f.main}.from-vp-account-6`, 'utf8'), target('账号旧目标'))
80
+ })
81
+
82
+ test('migration helper preserves existing conflict backups and safely finalizes unmarked accounts', async (t) => {
83
+ const { migratePersonalWorkspace } = await import('../lib/workspace-migration.js')
84
+ const f = fixture(t)
85
+ writeFileSync(f.main, target('合法个人目标'))
86
+ const backup = `${f.main}.from-vp-account-6`
87
+ writeFileSync(backup, target('更早的冲突副本'))
88
+ assert.deepEqual(migratePersonalWorkspace(f.root, 6), [])
89
+ assert.equal(readFileSync(f.main, 'utf8'), target('合法个人目标'))
90
+ assert.equal(readFileSync(backup, 'utf8'), target('更早的冲突副本'))
91
+ assert.equal(readFileSync(f.source, 'utf8'), target('账号旧目标'))
92
+ const marker = JSON.parse(readFileSync(f.marker, 'utf8'))
93
+ assert.equal(marker.files['TARGET.yaml'], 'conflict-preserved')
94
+ writeFileSync(join(f.accountDir, 'PROGRESS.yaml'), 'version: 1\nnodes: {}\n')
95
+ assert.deepEqual(migratePersonalWorkspace(f.root, 6), [])
96
+ assert.equal(existsSync(join(f.root, 'PROGRESS.yaml')), false)
97
+ })
98
+
99
+ test('migration helper validates account paths and skips absent accounts', async (t) => {
100
+ const { migratePersonalWorkspace } = await import('../lib/workspace-migration.js')
101
+ const f = fixture(t)
102
+ for (const accountId of ['../other', '-1', '6/subdir', true]) assert.throws(() => migratePersonalWorkspace(f.root, accountId), /账号 id/)
103
+ assert.deepEqual(migratePersonalWorkspace(f.root, 0), [])
104
+ assert.deepEqual(migratePersonalWorkspace(f.root, 7), [])
105
+ assert.equal(existsSync(f.marker), false)
34
106
  })
package/dev.sh CHANGED
@@ -35,7 +35,7 @@ done
35
35
  # 从副本目录跑 `bash dev.sh` 走的是另一条路(`dsh plugin remove/add`,而那条
36
36
  # 路不可靠),和这份的行为不一样。换句话说,**同一个命令,从哪个目录按回车
37
37
  # 决定跑哪份脚本** —— 两份 dev.sh 就是两份真相,正是这份文件在防的那种坑。
38
- SYNC_FILES='index.js package.json verify.mjs verify-shape.mjs progress-cli.mjs build-node-meta.mjs cordis.patch.yml README.md lib/client.js lib/vp-client.js lib/setup.js lib/curriculum.js lib/curriculum-evidence.js lib/curriculum-planning.js lib/strategy.js assets/knowledge/COMPETENCIES.yaml assets/knowledge/vp.html assets/rules/coach-rules.md dev/curriculum-check.mjs dev/curriculum-evidence-check.mjs dev/strategy-check.mjs dev/workspace-migration-check.mjs dev/vp-client-check.mjs docs/长期课程机制.md dev.sh'
38
+ SYNC_FILES='index.js package.json verify.mjs verify-shape.mjs progress-cli.mjs build-node-meta.mjs cordis.patch.yml README.md lib/client.js lib/vp-client.js lib/workspace-migration.js lib/setup.js lib/curriculum.js lib/curriculum-evidence.js lib/curriculum-planning.js lib/strategy.js lib/teaching.js assets/knowledge/COMPETENCIES.yaml assets/knowledge/NODE_SCOPES.yaml assets/knowledge/CONTEST_PROFILES.yaml assets/knowledge/vp.html assets/rules/coach-rules.md dev/curriculum-check.mjs dev/curriculum-evidence-check.mjs dev/strategy-check.mjs dev/workspace-migration-check.mjs dev/vp-client-check.mjs dev/vp-sync-check.mjs dev/catalog-check.mjs dev/coaching-contract-check.mjs dev/teaching-check.mjs dev/run-all.sh docs/长期课程机制.md docs/比赛与教学依据.md docs/设计方针.md dev.sh'
39
39
  STALE=''
40
40
  for f in $SYNC_FILES; do
41
41
  for d in "${DSTS[@]}"; do
@@ -93,7 +93,7 @@ echo '· 全部副本哈希一致'
93
93
  # 病因已修(那几节改跑临时副本),这条是**兜底闸门**:自检跑完比一次指纹,
94
94
  # 一变就报错退出。这种事不靠自觉,靠闸门。
95
95
  LEDGER_DIR="$DSH_HOME_BASH/knowledge"
96
- LEDGERS='PROGRESS.yaml SCHEDULE.yaml CURRICULUM.yaml'
96
+ LEDGERS='PROGRESS.yaml SCHEDULE.yaml CURRICULUM.yaml TARGET.yaml VP_EVENTS.yaml'
97
97
  ledger_fp() { (cd "$LEDGER_DIR" 2>/dev/null && sha256sum $LEDGERS 2>/dev/null || true); }
98
98
  LEDGER_BEFORE=$(ledger_fp)
99
99
 
@@ -118,6 +118,11 @@ echo '── 编排证据验收(目录、引用、事件身份与重复记录
118
118
  node --test "$SRC_BASH/dev/curriculum-evidence-check.mjs" || EVIDENCE_RC=$?
119
119
  EVIDENCE_RC=${EVIDENCE_RC:-0}
120
120
 
121
+ echo
122
+ echo '── 策略、教学目录、真实工具与账号数据回归 ──'
123
+ node --test "$SRC_BASH/dev/strategy-check.mjs" "$SRC_BASH/dev/catalog-check.mjs" "$SRC_BASH/dev/coaching-contract-check.mjs" "$SRC_BASH/dev/teaching-check.mjs" "$SRC_BASH/dev/workspace-migration-check.mjs" "$SRC_BASH/dev/vp-client-check.mjs" "$SRC_BASH/dev/vp-sync-check.mjs" || CONTRACT_RC=$?
124
+ CONTRACT_RC=${CONTRACT_RC:-0}
125
+
121
126
  LEDGER_AFTER=$(ledger_fp)
122
127
  if [ "$LEDGER_BEFORE" != "$LEDGER_AFTER" ]; then
123
128
  echo
@@ -149,4 +154,4 @@ if [ -n "$DSH_LIVE" ]; then
149
154
  echo ' 要验「工具挂进教练的清单里」,得先重启 dsh。'
150
155
  fi
151
156
 
152
- exit $(( VERIFY_RC + SHAPE_RC + CURRICULUM_RC + EVIDENCE_RC ))
157
+ exit $(( VERIFY_RC + SHAPE_RC + CURRICULUM_RC + EVIDENCE_RC + CONTRACT_RC ))
@@ -0,0 +1,79 @@
1
+ # 比赛依据与节点教学卡
2
+
3
+ 训练路线先回答“目标比赛里要完成什么任务、本人被什么障碍挡住”,再选知识点。Rating 可以帮助找题池,不能证明某项能力已掌握,也不能单独决定下一阶段。
4
+
5
+ ## 三种信息分别负责什么
6
+
7
+ | 资料 | 用途 | 不能据此声称什么 |
8
+ | --- | --- | --- |
9
+ | `COMPETENCIES.yaml` | 九项通用比赛能力;关联精确 MAP 节点 | 它们不是官方考纲,教学验收阈值不是比赛规则 |
10
+ | `NODE_SCOPES.yaml` | 十四个常用节点的识别条件、证明问题、适用范围、反例、延伸与迁移动作 | 卡片不是完整教材,完成一张卡不等于永久掌握 |
11
+ | `CONTEST_PROFILES.yaml` | 有限真题样本支持的诊断任务与取舍提示 | 不代表专题频率、奖牌线或未来题目预测 |
12
+
13
+ `NODE_SCOPES` 的 `id` 必须与 MAP 节点完全一致。`recognition` 用于读题识别;`proofQuestions` 用于确认学生能解释;`notFor` 用于主动暴露边界;`transferProblems` 是练习任务描述,不是假造的题目编号;`exitEvidence` 是教学验收建议。来源证明技术条件,练习设计由插件负责。
14
+
15
+ 三个 profile 当前均是 `provisional`:`regional-diagnostic`、`wf-extension`、`xian-pending`。这个状态表示可用于带限制的诊断,不能输出“已掌握本比赛考纲”。`reviewed` 也只能表示记录的样本已人工核验,不能去掉样本限制。
16
+
17
+ ## 本轮采样范围
18
+
19
+ 区域赛诊断取自 [NWERC 2024 官方题面](https://2024.nwerc.eu/main/problem-set.pdf) 的 A、D、E、F、K、L,与[官方解题](https://2024.nwerc.eu/main/solutions.pdf)对应章节核对。它们用来检查规则实现、状态设计、重复变换与多模型组合;没有采样全部题目,也没有跨年份计数。NWERC 与中国区域赛的差异保留为限制,不推导西安趋势。
20
+
21
+ WF 扩展诊断只读了 [2024 World Finals 官方题面](https://icpc.global/worldfinals/problems/2024-ICPC-World-Finals/icpc2024.pdf) 的 B、I、L,能力映射是教学推断。没有核验这些题的完整官方题解。交互程序的刷新与协议要求另依据[官方交互评测说明](https://docs.icpc.global/wp-content/uploads/2024/08/2024-JudgingNotes.pdf)。
22
+
23
+ [QOJ 西安目录](https://qoj.ac/category/87)只用于确认历年原题入口。当前没有完成原题、主办方题解、榜单的逐题核验,所以西安配置保持待采样,不生成专题权重。采用 [NCNA 公布的区域赛规则](https://nc.na.icpc.global/rules/)作通用赛制背景,也不能把它视作西安本届现场规则。
24
+
25
+ ## 小鲸怎样使用
26
+
27
+ 1. 先确认目标、剩余时间和个人/团队训练场景。选择 profile 时解释样本范围,不能从比赛名字推断考纲。
28
+ 2. 用 VP 可观察事实形成诊断假设。失败原因经代码或复盘确认后,才称为短板;成功记录保留为能力基线。
29
+ 3. 挑一个能完成验收的动作,说明它对应什么比赛任务、为何现在练、预计投入和改变安排的条件。
30
+ 4. 讲知识点时先要求识别和证明,接着给边界反例,再找未标注专题的新题与隔天复测。来源跟随卡片一起展示。
31
+
32
+ 用户临时问新知识点时可以回答。回答不自动成为主线任务,也不自动改变游标或阶段。
33
+
34
+ ## 2.5:给教练续课依据,不代替教练判断
35
+
36
+ 本版只增加 `coach_note` 的读写入口,笔记放进现有 `PROGRESS.nodes.<id>.lessonNotes`。
37
+ 记录是“当前教学判断 + 下一次接续点”;可选学生原话通过 dsh 原生 `sessionQuery`
38
+ 核对当前会话的真实用户消息,来源由程序生成。插件消息或压缩摘要不能成为学生原话。
39
+ 核对存在不证明理解,笔记也不改变游标、掌握状态、待办或检测结果。
40
+
41
+ `coach_status` 和原有会话首步注入只提供短笔记,详细材料按需读取。
42
+ 默认读取优先接续最近有笔记的未完动作,再看游标;无游标时看最近笔记。
43
+ 写入默认落在当前游标,指定节点时使用该节点,不改变游标本身。
44
+ 教学进展或中断值得保存时才写,不要求每轮留笔记,不规定固定工具顺序。
45
+ 能力排名作为参考,前三之外或尚未映射到能力的节点仍允许教练安排;
46
+ 课程、前置和写入证据的检查保留。`teachMinutes` 是计划,不自动生成已教学日期,
47
+ 实际开始学习仍由教练显式记录;旧记录保留,不重新推断发生过什么。
48
+
49
+ 这个取舍是结合本插件所做的设计判断:
50
+
51
+ * [Anthropic:Building effective agents](https://www.anthropic.com/engineering/building-effective-agents)
52
+ 与 [OpenAI:A practical guide to building agents](https://openai.com/business/guides-and-resources/a-practical-guide-to-building-ai-agents/)
53
+ 都建议从简单、可验证的方案开始。因此沿用小鲸这个 agent,未增加多教练编排或框架。
54
+ * [Anthropic:Effective context engineering](https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents)
55
+ 支持保留必要摘要、按需获取详情;本版沿用原进度文件与宿主会话查询,不复制聊天数据库。
56
+ * [Anthropic:Writing tools for agents](https://www.anthropic.com/engineering/writing-tools-for-agents)
57
+ 强调清楚、职责明确的工具与真实任务评测;本版把续课笔记与已有检测、日程工具分开,
58
+ 不用固定调用次序判断教练是否正确。
59
+ * [Anthropic:Demystifying evals for AI agents](https://www.anthropic.com/engineering/demystifying-evals-for-ai-agents)
60
+ 区分任务结果与交互质量;工具回归只能验证记录和来源,不能据此声称学生学习效果已证实。
61
+
62
+ 验证覆盖跨模块实例读取、原话来源、拒绝伪引用和并行更新时保留已有进度。
63
+ 没有做真实学生的长期对照试验,也没有把笔记数量当教学质量分数。
64
+
65
+ ## 四个明确的教学边界
66
+
67
+ 最短路卡提供有向边 `s→a:2`、`s→b:5`、`b→a:-4`:确定后不再更新的 Dijkstra 会过早确定 a;真实最短距离是 1。算法仍偶尔得到对的结果不能证明它适用于负权图。[技术依据](https://oi-wiki.org/graph/shortest-path/)
68
+
69
+ SCC 卡提供中心与三个叶子组成的双向星形图:四个点互达,但简单路径最多访问三个点。收集每个点必须保持简单路径时,不能把整个分量点权直接相加。[技术依据](https://oi-wiki.org/graph/scc/)
70
+
71
+ 线段树卡对比 `[0,4]` 与 `[2,2]`:两段长度和区间和相同,各项与 2 取最小值后的区间和分别是 2 和 4。只维护和及长度不足以支持此更新,需要补信息或更换算法。[技术依据](https://oi-wiki.org/ds/seg/)
72
+
73
+ DP 卡要求证明状态足够。同一个位置如果对应不同剩余资源,而后续可选动作依赖资源,就不能直接合并这两段历史;这是状态设计问题,增加模板数量不能解决。[技术依据](https://oi-wiki.org/dp/basic/)
74
+
75
+ ## 验证与范围
76
+
77
+ `node --test dev/catalog-check.mjs` 检查唯一 ID、真实 MAP 节点、能力映射、每张卡所需字段、公开来源链接、profile 状态与采样限制。该测试不向外网请求,也不读写用户训练账本。
78
+
79
+ 集成回归已验证 `coach_scope` 返回节点卡、profile 可被目标选中且保留限制,以及两场测量从公开导入到阶段推进的闭环。目录校验不等于小鲸对话效果评测,也没有验证具体迁移练习的完整 AC 程序。