acmer-coach 2.0.0 → 2.2.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/README.md CHANGED
@@ -20,7 +20,7 @@
20
20
 
21
21
  ## 它到底做了什么
22
22
 
23
- 26 个工具,三条轴:知识图、比赛能力、训练证据。
23
+ 29 个工具,三条轴:知识图、比赛能力、训练证据。
24
24
 
25
25
  | 轴 | 含义 |
26
26
  |---|---|
@@ -75,7 +75,7 @@ VP 记录格式是 JSON 数组,每场至少包含 `eventId`、`contest`、`dat
75
75
  ### 长期学习路线
76
76
 
77
77
  `coach_curriculum assess` 从已有检测、代码判因、做题记录和日程实际完成中列出
78
- 可引用的证据;不会把 rating、学习状态或取消的卷子当成短板。每条记录带内容引用
78
+ 可引用的证据;VP 题目与复盘里的 `competencies` 也会生成稳定证据 id,不会把 rating、学习状态或取消的卷子当成短板。每条记录带内容引用
79
79
  `id` 和事件身份 `eventId`。训练阶段的每个节点必须有真实记录:`finding=gap`
80
80
  引用失败/超时/依赖提示等短板,`finding=extension` 引用已独立做出或过卷的能力起点。
81
81
 
@@ -86,8 +86,7 @@ VP 记录格式是 JSON 数组,每场至少包含 `eventId`、`contest`、`dat
86
86
  learned/verified 记录,或路线中在此前安排的必验训练,不能从游标位置推定已经学会。
87
87
 
88
88
  `CURRICULUM.yaml` 保存路线与修订历史:`read` 查看,`create` 建立,`revise` 调整,
89
- `advance` 推进。进入阶段时保存证据事件快照;诊断阶段每个节点需有新的真实观察,
90
- 训练阶段所有节点必须 verified,且各有新的独立做出或过卷记录。改旧备注、成绩或
89
+ `advance` 推进。阶段可以只绑定比赛能力(读题、决策、团队沟通),这类阶段用 VP/复盘证据验收,不必伪装成地图节点。进入阶段时保存证据事件快照;诊断阶段每个节点需有新的真实观察,训练阶段所有节点必须 verified,能力目标必须有阶段后的新 baseline 证据。改旧备注、成绩或
91
90
  把同次做题从课表迁入进度,不算新训练。当前诊断也不能通过改种类或删节点逃过验收。
92
91
  写操作带 `expectedRevision` 和理由,避免旧上下文覆盖新计划。
93
92
  路线按剩余 `estimatedHours`、每周预算和目标日期提示容量不足或估算缺失;这只是
@@ -101,7 +100,17 @@ learned/verified 记录,或路线中在此前安排的必验训练,不能从
101
100
  `coach_plan` 继续管理近期时间块。旧 v1 路线可读,会标记 `needsReview`;需明确
102
101
  修订当前及后续阶段后迁移 v2,插件不会自动重写。优先级与教学方案仍由模型判断,
103
102
  程序现在会要求目标和 VP 证据先进入策略层;能力卡片仍由仓库维护,未知卡片不会自动臆造。
104
- 这版仍不包含后台定时同步和自动读取第三方 VP 网站,导出后用 `coach_vp_import` 导入。
103
+ 现在可以直接连接 VP:`coach_vp_connect start` 打开授权页,登录并允许只读访问后,
104
+ 把一次性 code 交给 `coach_vp_connect finish`。然后用 `coach_vp_sync` 增量同步比赛、
105
+ 提交、事件和个人历史;同步会自动生成带账号前缀的 VP 训练证据,仍由复盘和检测决定
106
+ 能力结论。`coach_vp_replay` 读取指定比赛的细粒度回放,`VP 回放`按钮会打开可视化页面,
107
+ 把 AC、WA/TLE/RE 和比赛分钟画在同一条时间轴上。
108
+
109
+ 每个 VP 账号都放在 `~/.dsh/knowledge/vp-accounts/<user_id>/`,token 单独保存,
110
+ 同步游标、原始安全投影和回放缓存都按账号分开。切换账号会切换教练工作区;服务端
111
+ 返回的账号 id 与当前账号不一致时,客户端拒绝写入。同步和回放都不返回提交代码或 judge 输出。
112
+ `disconnect` 只删除本地 token,保留缓存,方便重新连接同一个账号。
113
+
105
114
  详见 [长期课程机制](docs/长期课程机制.md)。
106
115
 
107
116
  **「学过」和「已验证」是两级,绝不合并。** 自评 ≠ 掌握 ——
@@ -0,0 +1,47 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <title>VP 回放 · 小鲸教练</title>
7
+ <style>
8
+ :root { color-scheme: dark; --bg:#0d1117; --panel:#161b22; --line:#30363d; --text:#e6edf3; --muted:#8b949e; --blue:#58a6ff; --green:#3fb950; --red:#f85149; --yellow:#d29922; }
9
+ * { box-sizing:border-box } body { margin:0; padding:24px; color:var(--text); background:radial-gradient(circle at 85% 0,#1b3150 0,#0d1117 45%); font:14px/1.55 system-ui,-apple-system,"Segoe UI",sans-serif; }
10
+ .wrap { max-width:1180px; margin:auto } h1 { margin:0 0 4px; font-size:25px } h2 { font-size:16px; margin:0 0 12px } .muted { color:var(--muted) }
11
+ .grid { display:grid; grid-template-columns:320px 1fr; gap:16px; margin-top:18px } .card { background:color-mix(in srgb,var(--panel) 92%,transparent); border:1px solid var(--line); border-radius:12px; padding:16px; box-shadow:0 10px 35px #0003; }
12
+ button,input { border:1px solid var(--line); border-radius:7px; padding:8px 10px; color:var(--text); background:#21262d; font:inherit } button { cursor:pointer; background:#1f6feb; border-color:#388bfd } button.secondary { background:#21262d } button:hover { filter:brightness(1.15) } input { width:100%; margin:5px 0 9px; background:#0d1117 }
13
+ .row { display:flex; gap:8px; align-items:center; flex-wrap:wrap } .row button { flex:1 } .status { padding:10px 12px; border-radius:8px; background:#0d1117; margin:10px 0 14px; color:var(--muted) } .status.ok { color:#aff5b4; border:1px solid #238636 }
14
+ .stats { display:grid; grid-template-columns:repeat(3,1fr); gap:8px; margin:12px 0 } .stat { padding:10px; border:1px solid var(--line); border-radius:8px; background:#0d1117 } .stat b { display:block; font-size:20px; color:var(--blue) }
15
+ #timeline { width:100%; min-height:220px; overflow:auto; border:1px solid var(--line); border-radius:8px; background:#0d1117; padding:12px } svg { width:100%; min-width:620px; height:190px } .legend { display:flex; gap:12px; color:var(--muted); font-size:12px; margin-top:6px } .dot { display:inline-block; width:9px; height:9px; border-radius:50%; margin-right:4px }
16
+ .problem { display:flex; justify-content:space-between; padding:8px 0; border-bottom:1px solid #21262d } .pill { border-radius:999px; padding:2px 8px; font-size:12px; background:#30363d } .pill.AC { color:#aff5b4; background:#173b25 } .pill.WA,.pill.TLE,.pill.RE { color:#ffb4ab; background:#4b1d20 }
17
+ #message { min-height:24px; margin-top:12px; color:var(--muted) } a { color:var(--blue) }
18
+ @media (max-width:760px) { body{padding:14px} .grid{grid-template-columns:1fr} }
19
+ </style>
20
+ </head>
21
+ <body><main class="wrap">
22
+ <h1>VP 回放</h1><div class="muted">账号数据按 VP user id 隔离;这里的时间轴是比赛墙上秒数。</div>
23
+ <div class="grid">
24
+ <section class="card"><h2>连接 VP</h2><div id="account" class="status">正在读取连接状态…</div>
25
+ <button id="start">打开 VP 登录授权</button>
26
+ <label class="muted">授权页显示的 code</label><input id="code" autocomplete="off" placeholder="粘贴一次性 code">
27
+ <button id="finish" class="secondary">完成连接</button><div class="row" style="margin-top:8px"><button id="sync" class="secondary">立即同步</button><button id="disconnect" class="secondary">断开账号</button></div>
28
+ <div id="authLink" class="muted"></div><div id="message"></div>
29
+ </section>
30
+ <section class="card"><h2>比赛回放</h2><div class="row"><input id="contest" type="number" min="1" placeholder="Contest ID"><button id="load">加载回放</button></div>
31
+ <div id="summary" class="muted" style="margin:12px 0">先连接 VP,再输入 Contest ID。</div><div id="timeline"></div><div id="problems"></div>
32
+ </section>
33
+ </div>
34
+ </main>
35
+ <script>
36
+ const $ = (id) => document.getElementById(id)
37
+ const say = (text, bad=false) => { $('message').textContent = text; $('message').style.color = bad ? '#f85149' : '' }
38
+ async function api(path, options) { const r=await fetch(path, {headers:{'content-type':'application/json'}, ...options}); const d=await r.json(); if(!r.ok||d.ok===false) throw new Error(d.reject||`HTTP ${r.status}`); return d }
39
+ async function status(){ try { const d=await api('./api/vp/status'); const a=d.account; $('account').textContent=d.connected?`${a.display_name||a.handle} · ${a.handle} · 账号 ${a.id}`:'未连接 VP'; $('account').className='status'+(d.connected?' ok':''); } catch(e){say(e.message,true)} }
40
+ $('start').onclick=async()=>{try{const d=await api('./api/vp/connect',{method:'POST',body:JSON.stringify({op:'start'})}); $('authLink').innerHTML=`<a href="${d.authorizeUrl}" target="_blank" rel="noreferrer">打开授权页</a>(登录并允许读取)`; say('授权页已打开,完成后把 code 粘贴到上面。'); window.open(d.authorizeUrl,'_blank','noopener');}catch(e){say(e.message,true)}}
41
+ $('finish').onclick=async()=>{try{await api('./api/vp/connect',{method:'POST',body:JSON.stringify({op:'finish',code:$('code').value})}); say('连接成功,工作区已切换。'); await status()}catch(e){say(e.message,true)}}
42
+ $('sync').onclick=async()=>{try{const d=await api('./api/vp/connect',{method:'POST',body:JSON.stringify({op:'sync'})}); say(`同步完成:${d.submissions?.length||0} 条提交。`); await status()}catch(e){say(e.message,true)}}
43
+ $('disconnect').onclick=async()=>{try{await api('./api/vp/connect',{method:'POST',body:JSON.stringify({op:'disconnect'})}); say('已断开,缓存保留。'); await status()}catch(e){say(e.message,true)}}
44
+ $('load').onclick=async()=>{try{const d=await api(`./api/vp/replay?contestId=${encodeURIComponent($('contest').value)}`); render(d); say('回放加载完成。')}catch(e){say(e.message,true)}}
45
+ function render(d){ const v=d.visualization||{}, ev=v.team_events||[], duration=Math.max(60,v.duration_seconds||1), w=900, pad=42; $('summary').innerHTML=`<b>${d.contest?.title||'Contest '+d.contest?.id}</b><div class="stats"><div class="stat"><b>${d.result?.own?.solved??d.result?.solved??0}</b> solved</div><div class="stat"><b>${ev.length}</b> team events</div><div class="stat"><b>${v.official_run_count||0}</b> official runs</div></div>`; let ticks=Array.from({length:6},(_,i)=>{const x=pad+(w-pad*2)*i/5;return `<line x1="${x}" y1="28" x2="${x}" y2="160" stroke="#21262d"/><text x="${x}" y="178" fill="#8b949e" text-anchor="middle">${Math.round(duration*i/5/60)}m</text>`}).join(''); let marks=ev.map(e=>{const x=pad+(w-pad*2)*Math.min(1,Math.max(0,(e.second||0)/duration));const good=String(e.verdict||'').toUpperCase()==='AC';const color=good?'#3fb950':'#f85149';return `<circle cx="${x}" cy="${good?70:112}" r="7" fill="${color}"/><title>${e.problem||''} · ${e.verdict||''} · ${Math.round((e.second||0)/60)}m</title>`}).join(''); $('timeline').innerHTML=`<svg viewBox="0 0 ${w} 190" role="img" aria-label="比赛提交时间轴"><text x="8" y="74" fill="#8b949e">AC</text><text x="8" y="116" fill="#8b949e">非 AC</text>${ticks}<line x1="${pad}" y1="70" x2="${w-pad}" y2="70" stroke="#3fb950"/><line x1="${pad}" y1="112" x2="${w-pad}" y2="112" stroke="#f85149"/>${marks}</svg><div class="legend"><span><i class="dot" style="background:#3fb950"></i>AC</span><span><i class="dot" style="background:#f85149"></i>WA/TLE/RE</span><span>横轴为比赛分钟</span></div>`; $('problems').innerHTML=(d.problems||[]).map(p=>`<div class="problem"><span>${p.label||p.id} ${p.title||''}</span><span class="pill">${p.id}</span></div>`).join('') }
46
+ status()
47
+ </script></body></html>
@@ -30,7 +30,7 @@
30
30
  必须填写 `basis`、`goalContribution`、`priorityReason`、`estimatedHours`、
31
31
  `evidenceRefs`、`finding`、`kind`、`uncertainty`、`diagnosticMethod`。
32
32
  说清观察到了什么、它怎样服务目标、为何排在这里、预计投入多少,不套统一算法清单。
33
- * `kind=training` 的每个节点都要对应真实证据:补短板用 `finding=gap`,引用 gap;
33
+ * `kind=training` 的每个节点都要对应真实证据:补短板用 `finding=gap`,引用 gap;读题、决策、团队沟通这类没有单一地图节点的阶段改用 `competencies`,引用 VP/复盘产生的能力证据;
34
34
  在已证实能力上进阶用 `finding=extension`,引用 baseline。记录指向什么由你解读,
35
35
  但**不能写一段理由替代不存在的记录**,也不能把别的节点的失败贴来凑依据。
36
36
  * 缺依据先安排 `kind=diagnostic/finding=unknown`,写尚待确认的问题和具体取证方法。
@@ -336,6 +336,26 @@ test('broken course is surfaced and never treated as an empty plan', () => {
336
336
  assert.equal(readFileSync(f.store.path, 'utf8'), 'revision: broken\n')
337
337
  })
338
338
 
339
+ test('competency-only phases can use fresh VP evidence as their exit gate', () => {
340
+ const f = fixture()
341
+ const evidence = [{ id: 'vp:old:contest-reading', eventId: 'old', source: 'vp', node: '', competency: 'contest-reading', date: '2026-10-08', signal: 'gap', summary: 'VP 读题漏读', fingerprint: 'vp:old:contest-reading' }]
342
+ f.options.loadStrategyEvidence = () => evidence
343
+ const store = createCurriculumStore(f.options)
344
+ const phase = {
345
+ id: 'reading', title: '比赛读题与筛选', outcome: '在前30分钟完成扫描并记录取舍', competencies: ['contest-reading'], nodes: [],
346
+ exitCriteria: '两场新 VP 都有扫描顺序、取舍理由和漏读检查', requiredVerified: [], kind: 'training', finding: 'gap',
347
+ basis: 'VP 已记录读题缺口,需要在真实比赛节奏中修复', goalContribution: '提高区域赛前半小时的可做题识别和取舍质量',
348
+ priorityReason: '它直接决定队伍是否把时间用在能拿分的题上', estimatedHours: 6, uncertainty: '', diagnosticMethod: '',
349
+ evidenceRefs: ['vp:old:contest-reading'],
350
+ }
351
+ assert.equal(store.execute({ action: 'create', expectedRevision: 0, goal: '区域赛稳定表现', baseline: '已有一场 VP 的读题缺口记录', weeklyHours: 30, phases: [phase], reason: '根据 VP 的读题记录建立能力课程' }).ok, true)
352
+ assert.deepEqual(store.snapshot().phaseCompetencies, ['contest-reading'])
353
+ assert.deepEqual(store.snapshot().missingFreshEvidence, ['contest-reading'])
354
+ evidence.push({ id: 'vp:new:contest-reading', eventId: 'new', source: 'vp', node: '', competency: 'contest-reading', date: '2026-10-09', signal: 'baseline', summary: '新 VP 读题记录', fingerprint: 'vp:new:contest-reading' })
355
+ assert.deepEqual(store.snapshot().missingFreshEvidence, [])
356
+ assert.equal(store.execute({ action: 'advance', expectedRevision: 1, reason: '新 VP 已记录扫描顺序和取舍理由,满足本阶段能力验收' }).status, 'complete')
357
+ })
358
+
339
359
  test('course tool output matches its declared schema on success, rejection and absence', () => {
340
360
  const f = fixture(); const tool = curriculumTool(f.store)
341
361
  for (const result of [f.store.execute({ action: 'read' }), f.create(), f.store.execute({ action: 'advance', expectedRevision: 1, reason: '检查未验收阶段是否能够被强行推进' })]) validateShape(tool.output.schema, result)
@@ -1,11 +1,12 @@
1
1
  import test from 'node:test'
2
2
  import assert from 'node:assert/strict'
3
- import { mkdtempSync, rmSync, existsSync } from 'node:fs'
3
+ import { mkdtempSync, rmSync, existsSync, writeFileSync } from 'node:fs'
4
4
  import { join } from 'node:path'
5
+ import { tmpdir } from 'node:os'
5
6
  import { createStrategyStore } from '../lib/strategy.js'
6
7
 
7
8
  function fixture() {
8
- const dir = mkdtempSync(join(process.cwd(), '.strategy-test-'))
9
+ const dir = mkdtempSync(join(tmpdir(), 'acmer-coach-strategy-'))
9
10
  const store = createStrategyStore({
10
11
  dataDir: dir,
11
12
  competenciesPath: join(process.cwd(), 'assets', 'knowledge', 'COMPETENCIES.yaml'),
@@ -73,3 +74,85 @@ test('first-use focus has a complete refusal shape and targets reject unknown ca
73
74
  assert.equal(rejected.ok, false)
74
75
  } finally { rmSync(dir, { recursive: true, force: true }) }
75
76
  })
77
+
78
+ test('target route rejects an unrelated progress node but allows a course-bound route', () => {
79
+ const { dir, store } = fixture()
80
+ try {
81
+ store.target('set', {
82
+ expectedRevision: 0, contest: '西安区域赛', date: '2026-10-18', result: '区域赛金牌',
83
+ weeklyHours: 35, priorities: [{ id: 'dp-modeling', weight: 100 }],
84
+ })
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)
91
+ } finally { rmSync(dir, { recursive: true, force: true }) }
92
+ })
93
+
94
+ test('VP problem competencies count as evidence before a postmortem exists', () => {
95
+ const { dir, store } = fixture()
96
+ try {
97
+ store.target('set', {
98
+ expectedRevision: 0, contest: '西安区域赛', date: '2026-10-18', result: '区域赛金牌',
99
+ weeklyHours: 35, priorities: [
100
+ { id: 'dp-modeling', weight: 80 },
101
+ { id: 'contest-reading', weight: 70 },
102
+ ],
103
+ })
104
+ store.vpImport([{ eventId: 'vp-2', contest: '训练 VP', date: '2026-10-10', durationMinutes: 300,
105
+ problems: [{ problemId: 'B', status: 'WA', competencies: ['dp-modeling'] }] }])
106
+ const focus = store.focus()
107
+ assert.equal(focus.focus[0].id, 'dp-modeling')
108
+ assert.equal(focus.focus[0].evidenceCount, 1)
109
+ } finally { rmSync(dir, { recursive: true, force: true }) }
110
+ })
111
+
112
+ test('strategy exposes stable competency evidence for curriculum gates', () => {
113
+ const { dir, store } = fixture()
114
+ try {
115
+ store.vpImport([{ eventId: 'vp-evidence', contest: '训练 VP', date: '2026-10-10', durationMinutes: 300,
116
+ problems: [{ problemId: 'C', status: 'WA', competencies: ['contest-decision'] }] }])
117
+ const result = store.evidence()
118
+ 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')
121
+ assert.equal(result.evidence[0].competency, 'contest-decision')
122
+ } finally { rmSync(dir, { recursive: true, force: true }) }
123
+ })
124
+
125
+ test('invalid VP input is rejected without bumping revision', () => {
126
+ const { dir, store } = fixture()
127
+ try {
128
+ const result = store.vpImport([{ eventId: '', contest: '坏记录', date: '2026-10-10', problems: [] }])
129
+ assert.equal(result.ok, false)
130
+ assert.equal(result.revision, 0)
131
+ assert.equal(result.imported, 0)
132
+ assert.equal(result.invalid.length, 1)
133
+ assert.equal(existsSync(join(dir, 'VP_EVENTS.yaml')), false)
134
+ } finally { rmSync(dir, { recursive: true, force: true }) }
135
+ })
136
+
137
+ test('invalid target priorities are rejected instead of silently dropped', () => {
138
+ const { dir, store } = fixture()
139
+ try {
140
+ const result = store.target('set', {
141
+ expectedRevision: 0, contest: '西安区域赛', date: '2026-10-18', result: '区域赛金牌',
142
+ weeklyHours: 35, priorities: [{ id: 'dp-modeling', weight: 'bad' }],
143
+ })
144
+ assert.equal(result.ok, false)
145
+ assert.match(result.reject, /priorit|weight|能力/)
146
+ assert.equal(result.revision, 0)
147
+ } finally { rmSync(dir, { recursive: true, force: true }) }
148
+ })
149
+
150
+ test('corrupt stored strategy data returns a structured refusal', () => {
151
+ const { dir, store } = fixture()
152
+ try {
153
+ writeFileSync(join(dir, 'TARGET.yaml'), 'version: 1\nrevision: 4\npriorities: bad\n', 'utf8')
154
+ const result = store.target('read')
155
+ assert.equal(result.ok, false)
156
+ assert.match(result.reject, /TARGET.yaml/)
157
+ } finally { rmSync(dir, { recursive: true, force: true }) }
158
+ })
@@ -0,0 +1,46 @@
1
+ import assert from 'node:assert/strict'
2
+ import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { createVpClient } from '../lib/vp-client.js'
6
+
7
+ const root = mkdtempSync(join(tmpdir(), 'acmer-coach-vp-'))
8
+ let account = { id: 1, handle: 'A', display_name: 'Coach A' }
9
+ let mismatch = false
10
+ const fetchImpl = async (url, options = {}) => {
11
+ const path = new URL(url).pathname
12
+ const body = options.body ? JSON.parse(options.body) : {}
13
+ if (path.endsWith('/api/coach/auth/start')) return json({ request_id: 'r1', authorize_url: 'https://vp/authorize/r1' })
14
+ if (path.endsWith('/api/coach/auth/exchange')) {
15
+ account = body.code === 'B' ? { id: 2, handle: 'B', display_name: 'Coach B' } : { id: 1, handle: 'A', display_name: 'Coach A' }
16
+ return json({ access_token: `token-${account.id}`, user: account })
17
+ }
18
+ if (path.endsWith('/api/coach/me')) return json({ user: account, read_only: true })
19
+ if (path.endsWith('/api/coach/sync')) return json({
20
+ account: mismatch ? { id: 1, handle: 'A' } : account,
21
+ submissions: [{ id: 9, contest_id: 7, verdict: 'AC' }], events: [], contests: [],
22
+ next_cursor: { submission_id: 9, event_id: 0 }, has_more: false,
23
+ })
24
+ if (path.endsWith('/api/coach/replay/7')) return json({ account, visualization: { team_events: [] } })
25
+ throw new Error(`unexpected ${url}`)
26
+ }
27
+ function json(value) { return { ok: true, json: async () => value, text: async () => JSON.stringify(value) } }
28
+
29
+ try {
30
+ const client = createVpClient({ dataDir: root, fetchImpl })
31
+ const start = await client.start({ baseUrl: 'https://vp/' })
32
+ assert.equal(start.requestId, 'r1')
33
+ await client.finish({ code: 'A' })
34
+ const synced = await client.sync()
35
+ assert.equal(synced.account.id, 1)
36
+ assert.equal(JSON.parse(readFileSync(join(root, 'vp-accounts', '1', 'SYNC.json'))).account.id, 1)
37
+ await client.finish({ code: 'B' })
38
+ assert.equal((await client.status()).account.id, 2)
39
+ assert.equal((await client.sync()).account.id, 2)
40
+ mismatch = true
41
+ await assert.rejects(() => client.sync(), /account mismatch/)
42
+ assert.equal(JSON.parse(readFileSync(join(root, 'vp-accounts', '2', 'SYNC.json'))).account.id, 2)
43
+ console.log('VP CLIENT ALL PASSED')
44
+ } finally {
45
+ rmSync(root, { recursive: true, force: true })
46
+ }
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/setup.js lib/curriculum.js lib/curriculum-evidence.js lib/curriculum-planning.js lib/strategy.js assets/knowledge/COMPETENCIES.yaml assets/rules/coach-rules.md dev/curriculum-check.mjs dev/curriculum-evidence-check.mjs dev/strategy-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/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
@@ -59,8 +59,11 @@ assess 最多返回最后 200 条,并给出 `evidenceCount/evidenceTruncated`
59
59
 
60
60
  ## 阶段如何编排
61
61
 
62
- 基础字段 `id/title/outcome/nodes/exitCriteria` 分别标识阶段、能力成果、地图节点和
63
- 验收方法;一个阶段 1–50 个不重复的真实节点,整条路线 1–30 个阶段。还必须提供:
62
+ 基础字段 `id/title/outcome/nodes/competencies/exitCriteria` 分别标识阶段、能力成果、地图节点、比赛能力和验收方法;一个阶段至少绑定一个真实地图节点或比赛能力,地图节点最多 50 个,整条路线 1–30 个阶段。读题、决策、团队沟通这类没有单一算法节点的阶段直接填 `competencies`。
63
+
64
+ VP 题目里的 `competencies` 和复盘里的 `competencies` 会生成稳定证据 id(如 `vp:event:problem:competency`),进入课程时被快照;能力阶段只能用进入阶段后的新 baseline 记录通过 `advance`。
65
+
66
+ 还必须提供:
64
67
 
65
68
  | 字段 | 要回答的问题 |
66
69
  |---|---|
@@ -76,7 +79,7 @@ assess 最多返回最后 200 条,并给出 `evidenceCount/evidenceTruncated`
76
79
 
77
80
  三种可编排阶段:
78
81
 
79
- * **training**:每个节点都必须有对应证据。`finding=gap` 必须引用该节点的 gap;
82
+ * **training**:每个节点或比赛能力都必须有对应证据。`finding=gap` 必须引用该节点/能力的 gap;
80
83
  `finding=extension` 必须引用该节点的 baseline。无关节点的失败不能用来凑依据。
81
84
  所有节点都必须列入 `requiredVerified`,不能用空数组逃过检测。
82
85
  * **diagnostic**:`finding=unknown`,明确待确认问题、取证方法与能力目标的关系。
@@ -111,7 +114,7 @@ assess 最多返回最后 200 条,并给出 `evidenceCount/evidenceTruncated`
111
114
  进入阶段时保存 `enteredAt/entryEvidenceRefs/entryEvidenceEvents`。advance 必须同时满足:
112
115
 
113
116
  * diagnostic:每个节点都有进入时未出现、日期不早于进入日的新有效观察。
114
- * training:全部节点 verified,且每个节点有本阶段新的独立做出或检测 passed 事件。
117
+ * training:地图节点全部 verified,且每个节点有本阶段新的独立做出或检测 passed 事件;能力目标全部有本阶段新的 baseline 证据。
115
118
  * 当前阶段没有待完成动作,也没有检测卷等待判定。
116
119
 
117
120
  旧记录改内容或换来源不能替代新事件。诊断阶段不能通过 revise 改成训练或删除待诊断