acmer-coach 2.3.0 → 2.3.1

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/README.md CHANGED
@@ -108,7 +108,10 @@ learned/verified 记录,或路线中在此前安排的必验训练,不能从
108
108
  把 AC、WA/TLE/RE 和比赛分钟画在同一条时间轴上。
109
109
 
110
110
  每个 VP 账号都放在 `~/.dsh/knowledge/vp-accounts/<user_id>/`,token 单独保存,
111
- 同步游标、原始安全投影和回放缓存都按账号分开。切换账号会切换教练工作区;服务端
111
+ 同步游标、原始安全投影、回放缓存和 `VP_EVENTS.yaml` 都按账号分开。个人训练数据
112
+ `PROGRESS.yaml`、`CURRICULUM.yaml`、`SCHEDULE.yaml`、`TARGET.yaml` 始终留在
113
+ `~/.dsh/knowledge/`,切换 VP 账号不会换教练。2.3.1 会把旧版本误写进账号目录的个人文件按更新时间迁回;被替换的主目录文件会留下 `.pre-vp-account-<user_id>` 备份,原账号文件不删除。
114
+ 服务端
112
115
  返回的账号 id 与当前账号不一致时,客户端拒绝写入。同步和回放都不返回提交代码或 judge 输出。
113
116
  `disconnect` 只删除本地 token,保留缓存,方便重新连接同一个账号。
114
117
 
@@ -147,6 +147,31 @@ test('invalid target priorities are rejected instead of silently dropped', () =>
147
147
  } finally { rmSync(dir, { recursive: true, force: true }) }
148
148
  })
149
149
 
150
+ test('personal target stays shared while VP events stay account-scoped', () => {
151
+ const personal = mkdtempSync(join(tmpdir(), 'acmer-coach-personal-'))
152
+ const accountA = mkdtempSync(join(tmpdir(), 'acmer-coach-vp-a-'))
153
+ const accountB = mkdtempSync(join(tmpdir(), 'acmer-coach-vp-b-'))
154
+ const options = {
155
+ competenciesPath: join(process.cwd(), 'assets', 'knowledge', 'COMPETENCIES.yaml'),
156
+ }
157
+ try {
158
+ const a = createStrategyStore({ ...options, dataDir: personal, vpDir: accountA })
159
+ const b = createStrategyStore({ ...options, dataDir: personal, vpDir: accountB })
160
+ const target = { expectedRevision: 0, contest: '西安区域赛', date: '2026-10-17', result: '保铜争银', weeklyHours: 25,
161
+ priorities: [{ id: 'implementation-stability', weight: 100 }] }
162
+ assert.equal(a.target('set', target).ok, true)
163
+ assert.equal(a.vpImport([{ eventId: 'account-a', contest: 'VP A', date: '2026-10-10', durationMinutes: 300,
164
+ problems: [{ problemId: 'A', status: 'AC' }] }]).ok, true)
165
+ assert.equal(b.target('read').contest, '西安区域赛')
166
+ assert.equal(b.vpImport([]).total, 0)
167
+ assert.equal(existsSync(join(personal, 'TARGET.yaml')), true)
168
+ assert.equal(existsSync(join(accountA, 'VP_EVENTS.yaml')), true)
169
+ assert.equal(existsSync(join(accountB, 'VP_EVENTS.yaml')), false)
170
+ } finally {
171
+ for (const dir of [personal, accountA, accountB]) rmSync(dir, { recursive: true, force: true })
172
+ }
173
+ })
174
+
150
175
  test('corrupt stored strategy data returns a structured refusal', () => {
151
176
  const { dir, store } = fixture()
152
177
  try {
@@ -0,0 +1,34 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
4
+ import { join } from 'node:path'
5
+ import { tmpdir } from 'node:os'
6
+
7
+ test('startup migrates legacy personal files out of an active VP account', async () => {
8
+ const root = mkdtempSync(join(tmpdir(), 'acmer-coach-workspace-'))
9
+ const accountDir = join(root, 'vp-accounts', '6')
10
+ mkdirSync(accountDir, { recursive: true })
11
+ writeFileSync(join(root, 'VP_ACCOUNTS.json'), JSON.stringify({
12
+ version: 1,
13
+ activeAccountId: 6,
14
+ accounts: { 6: { account: { id: 6, handle: 'Mianiii', display_name: 'Mianiii' }, baseUrl: 'https://example.test' } },
15
+ }))
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
+ }
34
+ })
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/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/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'
39
39
  STALE=''
40
40
  for f in $SYNC_FILES; do
41
41
  for d in "${DSTS[@]}"; do
package/index.js CHANGED
@@ -23,7 +23,7 @@
23
23
  // · `file:` 依赖装出来的是**硬链接**,不是拷贝。原地写两边同变,但改名式
24
24
  // 写入(Edit 工具、VS Code 原子保存)会**断开链接**,断了两边分家,
25
25
  // 而 `dsh plugin add` 认不出分家、不重链也不报错 —— 所以改完一律跑 dev.sh。
26
- import { copyFileSync, mkdirSync, readFileSync, unlinkSync, writeFileSync, renameSync, statSync } from 'node:fs'
26
+ import { copyFileSync, mkdirSync, readFileSync, unlinkSync, writeFileSync, renameSync, statSync, existsSync } from 'node:fs'
27
27
  import { join } from 'node:path'
28
28
  import { homedir } from 'node:os'
29
29
  import { parse, stringify } from 'yaml'
@@ -36,7 +36,7 @@ import { createVpClient } from './lib/vp-client.js'
36
36
  const name = 'acmer-coach'
37
37
  const inject = ['tools']
38
38
 
39
- const VERSION = '2.2.0'
39
+ const VERSION = '2.3.1'
40
40
 
41
41
  // 块结构(方针 §4.1.1):40 自己做 + 10 解决遗留 + 10 重写 = 60 分钟。
42
42
  // 这是**一个题目循环**的固定形状。时间不够该换更小的题,不是把块压扁。
@@ -72,30 +72,66 @@ const TEACH_FLOOR_MIN = 10
72
72
  const DATA_DIR_EXPLICIT = Boolean(process.env.COACH_DATA_DIR)
73
73
  const DATA_DIR = process.env.COACH_DATA_DIR || join(homedir(), '.dsh', 'knowledge')
74
74
  const MAP_PATH = () => join(DATA_DIR, 'MAP.yaml')
75
- let ACTIVE_DATA_DIR = DATA_DIR
75
+ let ACTIVE_VP_DIR = DATA_DIR
76
76
  let strategy
77
77
  let curriculum
78
- function createStores(dataDir) {
78
+ function createStores(vpDir) {
79
79
  strategy = createStrategyStore({
80
- dataDir, competenciesPath: join(import.meta.dirname, 'assets', 'knowledge', 'COMPETENCIES.yaml'),
80
+ dataDir: DATA_DIR, vpDir,
81
+ competenciesPath: join(import.meta.dirname, 'assets', 'knowledge', 'COMPETENCIES.yaml'),
81
82
  })
82
83
  curriculum = createCurriculumStore({
83
- dataDir, loadMap, loadProgress, loadSchedule,
84
+ dataDir: DATA_DIR, loadMap, loadProgress, loadSchedule,
84
85
  loadStrategyEvidence: () => strategy.evidence().evidence,
85
86
  })
86
87
  }
87
- createStores(ACTIVE_DATA_DIR)
88
+ createStores(ACTIVE_VP_DIR)
89
+
90
+ const PERSONAL_FILES = ['PROGRESS.yaml', 'CURRICULUM.yaml', 'SCHEDULE.yaml', 'TARGET.yaml']
91
+
92
+ // 2.3.0 曾把个人文件误写进 VP 账号目录。迁移时按 mtime 取较新的一份;
93
+ // 被替换的主目录文件留备份,原账号目录也保留,方便人工核对和恢复。
94
+ function migratePersonalWorkspace(accountId) {
95
+ if (!accountId) return []
96
+ const sourceDir = join(DATA_DIR, 'vp-accounts', String(accountId))
97
+ if (!existsSync(sourceDir)) return []
98
+ const migrated = []
99
+ for (const file of PERSONAL_FILES) {
100
+ const from = join(sourceDir, file)
101
+ const to = join(DATA_DIR, file)
102
+ if (!existsSync(from)) continue
103
+ if (!existsSync(to)) {
104
+ copyFileSync(from, to)
105
+ migrated.push(file)
106
+ continue
107
+ }
108
+ const sourceStat = statSync(from)
109
+ const targetStat = statSync(to)
110
+ if (sourceStat.mtimeMs <= targetStat.mtimeMs) continue
111
+ const sourceBody = readFileSync(from)
112
+ const targetBody = readFileSync(to)
113
+ if (sourceBody.equals(targetBody)) continue
114
+ const backup = `${to}.pre-vp-account-${String(accountId)}`
115
+ if (!existsSync(backup)) copyFileSync(to, backup)
116
+ copyFileSync(from, to)
117
+ migrated.push(`${file}(更新)`)
118
+ }
119
+ return migrated
120
+ }
88
121
 
89
122
  function switchCoachWorkspace(accountId = '') {
90
- ACTIVE_DATA_DIR = accountId ? join(DATA_DIR, 'vp-accounts', String(accountId)) : DATA_DIR
91
- mkdirSync(ACTIVE_DATA_DIR, { recursive: true })
92
- createStores(ACTIVE_DATA_DIR)
93
- return ACTIVE_DATA_DIR
123
+ ACTIVE_VP_DIR = accountId ? join(DATA_DIR, 'vp-accounts', String(accountId)) : DATA_DIR
124
+ mkdirSync(ACTIVE_VP_DIR, { recursive: true })
125
+ createStores(ACTIVE_VP_DIR)
126
+ return ACTIVE_VP_DIR
94
127
  }
95
128
 
96
129
  const vpClient = createVpClient({ dataDir: DATA_DIR })
97
130
  const savedVp = vpClient.localStatus()
98
- if (savedVp.connected) switchCoachWorkspace(savedVp.activeAccountId)
131
+ if (savedVp.connected) {
132
+ migratePersonalWorkspace(savedVp.activeAccountId)
133
+ switchCoachWorkspace(savedVp.activeAccountId)
134
+ }
99
135
 
100
136
  // 探针要的文件,各自代表一条后面的依赖:
101
137
  // MAP.yaml 地图(coach_next 读它算候选)
@@ -187,7 +223,7 @@ function loadMap() {
187
223
  //
188
224
  // 游标显式存,不靠"已验证的最大深度"推 —— 推出来的东西没法手改,
189
225
  // 而且他想复习某节时游标要能停在那儿。
190
- const PROGRESS_PATH = () => join(ACTIVE_DATA_DIR, 'PROGRESS.yaml')
226
+ const PROGRESS_PATH = () => join(DATA_DIR, 'PROGRESS.yaml')
191
227
 
192
228
  function emptyProgress() {
193
229
  return { version: 1, cursor: '', updated: '', nodes: {}, pending: null }
@@ -326,7 +362,7 @@ function saveProgress(p) {
326
362
  //
327
363
  // `actual` 是**校准真实做题速度**的唯一来源 —— 计划 60 分钟实际 95 分钟,
328
364
  // 攒几周才知道他的速度到底是多少。这条现在一条数据都没有,所以排程只能保守。
329
- const SCHEDULE_PATH = () => join(ACTIVE_DATA_DIR, 'SCHEDULE.yaml')
365
+ const SCHEDULE_PATH = () => join(DATA_DIR, 'SCHEDULE.yaml')
330
366
 
331
367
  function emptySchedule() {
332
368
  return { version: 1, updated: '', days: {} }
@@ -1539,7 +1575,11 @@ function registerPages(ctx) {
1539
1575
  try {
1540
1576
  let result
1541
1577
  if (payload.op === 'start') result = await vpClient.start({ baseUrl: payload.baseUrl })
1542
- else if (payload.op === 'finish') { result = await vpClient.finish({ code: payload.code, baseUrl: payload.baseUrl }); switchCoachWorkspace(result.activeAccountId) }
1578
+ else if (payload.op === 'finish') {
1579
+ result = await vpClient.finish({ code: payload.code, baseUrl: payload.baseUrl })
1580
+ migratePersonalWorkspace(result.activeAccountId)
1581
+ switchCoachWorkspace(result.activeAccountId)
1582
+ }
1543
1583
  else if (payload.op === 'disconnect') { result = vpClient.disconnect(); switchCoachWorkspace('') }
1544
1584
  else if (payload.op === 'sync') result = await vpClient.sync({})
1545
1585
  else return sendJson(res, 400, { ok: false, reject: 'op 只能是 start/finish/disconnect/sync' })
@@ -1953,6 +1993,7 @@ function apply(ctx) {
1953
1993
  if (action === 'start') result = await vpClient.start({ baseUrl: base })
1954
1994
  else if (action === 'finish') {
1955
1995
  result = await vpClient.finish({ code: args?.code, baseUrl: base })
1996
+ migratePersonalWorkspace(result.activeAccountId)
1956
1997
  switchCoachWorkspace(result.activeAccountId)
1957
1998
  } else if (action === 'disconnect') {
1958
1999
  result = vpClient.disconnect()
package/lib/strategy.js CHANGED
@@ -105,9 +105,9 @@ function normalizePostmortem(value) {
105
105
  }
106
106
  }
107
107
 
108
- export function createStrategyStore({ dataDir, competenciesPath, now = () => new Date() }) {
108
+ export function createStrategyStore({ dataDir, vpDir = dataDir, competenciesPath, now = () => new Date() }) {
109
109
  const targetPath = join(dataDir, 'TARGET.yaml')
110
- const vpPath = join(dataDir, 'VP_EVENTS.yaml')
110
+ const vpPath = join(vpDir, 'VP_EVENTS.yaml')
111
111
  const validateTarget = (doc) => {
112
112
  const errors = []
113
113
  if (doc.version !== 1) errors.push('version 必须是 1')
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "acmer-coach",
3
3
  "description": "小鲸教练插件:把「位置 + 判断」从小鲸的转述里拿回来。读 MAP/DEPENDS/PROGRESS,给唯一动作、判因、检测。热插拔,删掉即还原。",
4
- "version": "2.3.0",
4
+ "version": "2.3.1",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/Miantiao1231/acmer-coach.git"