acmer-coach 2.3.1 → 2.4.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/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, existsSync } from 'node:fs'
26
+ import { copyFileSync, mkdirSync, readFileSync, unlinkSync, writeFileSync, renameSync, statSync } from 'node:fs'
27
27
  import { join } from 'node:path'
28
28
  import { homedir } from 'node:os'
29
29
  import { parse, stringify } from 'yaml'
@@ -32,11 +32,12 @@ import { initDataDir, syncCodeforces, contactEvidence, importPool, importRecords
32
32
  import { createCurriculumStore, curriculumTool } from './lib/curriculum.js'
33
33
  import { createStrategyStore } from './lib/strategy.js'
34
34
  import { createVpClient } from './lib/vp-client.js'
35
+ import { migratePersonalWorkspace as migrateWorkspacePersonalFiles } from './lib/workspace-migration.js'
35
36
 
36
37
  const name = 'acmer-coach'
37
38
  const inject = ['tools']
38
39
 
39
- const VERSION = '2.3.1'
40
+ const VERSION = '2.4.0'
40
41
 
41
42
  // 块结构(方针 §4.1.1):40 自己做 + 10 解决遗留 + 10 重写 = 60 分钟。
42
43
  // 这是**一个题目循环**的固定形状。时间不够该换更小的题,不是把块压扁。
@@ -87,36 +88,8 @@ function createStores(vpDir) {
87
88
  }
88
89
  createStores(ACTIVE_VP_DIR)
89
90
 
90
- const PERSONAL_FILES = ['PROGRESS.yaml', 'CURRICULUM.yaml', 'SCHEDULE.yaml', 'TARGET.yaml']
91
-
92
- // 2.3.0 曾把个人文件误写进 VP 账号目录。迁移时按 mtime 取较新的一份;
93
- // 被替换的主目录文件留备份,原账号目录也保留,方便人工核对和恢复。
94
91
  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
92
+ return migrateWorkspacePersonalFiles(DATA_DIR, accountId)
120
93
  }
121
94
 
122
95
  function switchCoachWorkspace(accountId = '') {
@@ -1909,6 +1882,15 @@ const vpAccount = (value) => value && Number(value.id) > 0
1909
1882
  : emptyVpAccount()
1910
1883
 
1911
1884
  function vpEventsFromSync(payload, account) {
1885
+ // The platform stores timezone-less timestamps as UTC, not the client machine's local time.
1886
+ const instant = value => {
1887
+ const text = String(value || '').trim()
1888
+ const normalized = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?$/.test(text) ? `${text.replace(' ', 'T')}Z` : text
1889
+ const day = normalized.slice(0, 10)
1890
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(day) || !Number.isFinite(Date.parse(`${day}T12:00:00Z`)) || new Date(`${day}T12:00:00Z`).toISOString().slice(0, 10) !== day) return ''
1891
+ const stamp = Date.parse(normalized)
1892
+ return Number.isFinite(stamp) ? new Date(stamp).toISOString() : ''
1893
+ }
1912
1894
  const submissions = Array.isArray(payload?.submissions) ? payload.submissions : []
1913
1895
  const contests = Array.isArray(payload?.contests) ? payload.contests : []
1914
1896
  const byContest = new Map()
@@ -1926,15 +1908,18 @@ function vpEventsFromSync(payload, account) {
1926
1908
  return [...groups.entries()].map(([contestId, problems]) => {
1927
1909
  const contest = byContest.get(contestId) || {}
1928
1910
  const start = String(contest.start_time || [...problems.values()][0]?.[0]?.submitted_at || '')
1911
+ const startedAt = instant(start)
1929
1912
  const normalized = [...problems.entries()].map(([problemId, attempts]) => {
1913
+ attempts.sort((a, b) => String(a.submitted_at || '').localeCompare(String(b.submitted_at || '')) || Number(a.id) - Number(b.id))
1930
1914
  const solved = attempts.find((item) => ['AC', 'OK', 'ACCEPTED', 'SOLVED'].includes(String(item.verdict || '').toUpperCase()))
1931
- const at = solved?.submitted_at || attempts.at(-1)?.submitted_at
1932
- const minutes = start && at ? Math.max(0, Math.round((Date.parse(at) - Date.parse(start)) / 60000)) : 0
1933
- return { problemId, status: solved ? 'AC' : String(attempts.at(-1)?.verdict || 'UNKNOWN').toUpperCase(), attempts: attempts.length, solveMinutes: minutes, competencies: [] }
1915
+ const at = instant(solved?.submitted_at || attempts.at(-1)?.submitted_at)
1916
+ const minutes = startedAt && at ? Math.max(0, Math.round((Date.parse(at) - Date.parse(startedAt)) / 60000)) : 0
1917
+ return { problemId, status: solved ? 'AC' : String(attempts.at(-1)?.verdict || 'UNKNOWN').toUpperCase(), attempts: attempts.length,
1918
+ solveMinutes: minutes, competencies: [], ...(at && Number.isFinite(Date.parse(at)) ? { occurredAt: at } : {}) }
1934
1919
  })
1935
1920
  return {
1936
1921
  eventId: `vp:${account.id}:${contestId}`,
1937
- contest: String(contest.title || contestId), contestId: String(contestId), date: start.slice(0, 10),
1922
+ contest: String(contest.title || contestId), contestId: String(contestId), date: startedAt ? ymd(new Date(startedAt)) : start.slice(0, 10),
1938
1923
  durationMinutes: Number(contest.duration_min || 0), virtual: true, teamMode: 'team', problems: normalized,
1939
1924
  note: '由 VP 只读同步生成;具体能力标签在复盘时补充。',
1940
1925
  }
@@ -2003,7 +1988,7 @@ function apply(ctx) {
2003
1988
  return { ok: true, reject: '', action, connected: Boolean(result.connected ?? action === 'finish'), activeAccountId: Number(result.activeAccountId || account.id || 0), account,
2004
1989
  requestId: String(result.requestId || ''), authorizeUrl: String(result.authorizeUrl || ''), expiresAt: String(result.expiresAt || ''),
2005
1990
  text: action === 'start' ? `打开授权页登录 VP:${result.authorizeUrl}\n授权后把页面显示的 code 交给 coach_vp_connect finish。` :
2006
- action === 'finish' ? `VP 已连接:${account.handle}(账号 ${account.id});本地工作区已切换。` :
1991
+ action === 'finish' ? `VP 已连接:${account.handle}(账号 ${account.id});VP 缓存按账号隔离,个人课程和进度沿用。` :
2007
1992
  action === 'disconnect' ? 'VP 已断开;缓存保留,token 已删除。' : (result.connected ? `当前 VP:${account.handle}(账号 ${account.id})` : '当前没有已连接的 VP 账号。') }
2008
1993
  } catch (err) {
2009
1994
  return { ok: false, reject: String(err?.message || err), action, connected: false, activeAccountId: 0, account: emptyVpAccount(), requestId: '', authorizeUrl: '', expiresAt: '', text: '' }
@@ -2013,7 +1998,7 @@ function apply(ctx) {
2013
1998
 
2014
1999
  ctx.tools.register(defineTool({
2015
2000
  name: 'coach_vp_sync',
2016
- description: '从当前 VP 账号增量同步比赛、提交、事件和个人时间线。只读;游标和缓存都放在当前账号目录,切换账号不会读到另一人的数据。同步后自动生成带账号前缀的 VP 训练证据。',
2001
+ description: '从当前 VP 账号增量读取一页比赛、提交、事件和个人时间线。hasMore 为 true 时继续调用;pageCounts 是本页拉取数,返回数组是累计缓存。VP 平台只读,游标和缓存按账号隔离。同步后写入或更新带账号前缀的 VP 训练记录,导入被拒绝时返回 ok:false。',
2017
2002
  parameters: {
2018
2003
  limit: { type: 'integer', description: '单次最多同步多少条提交,默认 200' },
2019
2004
  includeProfile: { type: 'boolean', description: '是否同步个人 rating/比赛历史,默认 true' },
@@ -2021,7 +2006,13 @@ function apply(ctx) {
2021
2006
  output: { schema: { type: 'object', additionalProperties: false, properties: {
2022
2007
  ok: { type: 'boolean', required: true }, reject: { type: 'string', required: true }, account: { type: 'object', required: true, additionalProperties: false, properties: { id: { type: 'integer', required: true }, handle: { type: 'string', required: true }, display_name: { type: 'string', required: true } } },
2023
2008
  imported: { type: 'integer', required: true }, contests: { type: 'array', required: true }, submissions: { type: 'array', required: true }, events: { type: 'array', required: true },
2024
- nextCursor: { type: 'object', required: true, additionalProperties: true }, cachedPath: { type: 'string', required: true }, text: { type: 'string', required: true },
2009
+ hasMore: { type: 'boolean', required: true },
2010
+ pageCounts: { type: 'object', required: true, additionalProperties: false, properties: {
2011
+ contests: { type: 'integer', required: true }, submissions: { type: 'integer', required: true }, events: { type: 'integer', required: true },
2012
+ } },
2013
+ nextCursor: { type: 'object', required: true, additionalProperties: false, properties: {
2014
+ submission_id: { type: 'integer', required: true }, event_id: { type: 'integer', required: true },
2015
+ } }, cachedPath: { type: 'string', required: true }, text: { type: 'string', required: true },
2025
2016
  } }, render: (_a, v) => strategyText(v) },
2026
2017
  execute: async (args) => {
2027
2018
  try {
@@ -2029,11 +2020,17 @@ function apply(ctx) {
2029
2020
  const account = vpAccount(payload.account)
2030
2021
  switchCoachWorkspace(account.id)
2031
2022
  const importedEvents = vpEventsFromSync(payload, account)
2032
- const imported = importedEvents.length ? strategy.vpImport(importedEvents) : { imported: 0 }
2033
- return { ok: true, reject: '', account, imported: Number(imported.imported || 0), contests: payload.contests || [], submissions: payload.submissions || [], events: payload.events || [],
2034
- nextCursor: payload.next_cursor || {}, cachedPath: String(payload.cachedPath || ''), text: `VP 同步完成:${account.handle};比赛 ${payload.contests?.length || 0} 场,提交 ${payload.submissions?.length || 0} 条,新增训练证据 ${imported.imported || 0} 场。` }
2023
+ const imported = importedEvents.length ? strategy.vpImport(importedEvents, { source: 'sync' }) : { ok: true, reject: '', imported: 0 }
2024
+ const hasMore = payload.hasMore === true
2025
+ const pageCounts = payload.pageCounts
2026
+ return { ok: imported.ok, reject: String(imported.reject || ''), account, imported: Number(imported.imported || 0), contests: payload.contests || [], submissions: payload.submissions || [], events: payload.events || [],
2027
+ hasMore, pageCounts, nextCursor: { submission_id: Number(payload.cursor?.submission_id || 0), event_id: Number(payload.cursor?.event_id || 0) }, cachedPath: String(payload.cachedPath || ''),
2028
+ text: imported.ok
2029
+ ? `VP 本页同步:${account.handle};本页拉取比赛 ${pageCounts.contests} 场、提交 ${pageCounts.submissions} 条、事件 ${pageCounts.events} 条;缓存累计比赛 ${payload.contests?.length || 0} 场、提交 ${payload.submissions?.length || 0} 条、事件 ${payload.events?.length || 0} 条;写入或更新训练记录 ${imported.imported || 0} 场。${hasMore ? '仍有下一页,请继续调用 coach_vp_sync。' : '分页读取完成。'}`
2030
+ : `VP 缓存已同步,训练记录导入被拒绝:${imported.reject}` }
2035
2031
  } catch (err) {
2036
- return { ok: false, reject: String(err?.message || err), account: emptyVpAccount(), imported: 0, contests: [], submissions: [], events: [], nextCursor: {}, cachedPath: '', text: '' }
2032
+ return { ok: false, reject: String(err?.message || err), account: emptyVpAccount(), imported: 0, contests: [], submissions: [], events: [], hasMore: false,
2033
+ pageCounts: { contests: 0, submissions: 0, events: 0 }, nextCursor: { submission_id: 0, event_id: 0 }, cachedPath: '', text: '' }
2037
2034
  }
2038
2035
  },
2039
2036
  }))
@@ -2083,15 +2080,16 @@ function apply(ctx) {
2083
2080
 
2084
2081
  ctx.tools.register(defineTool({
2085
2082
  name: 'coach_target',
2086
- description: '设置或读取比赛目标。目标先于知识点:contest/date/result/teamMode/weeklyHours/priorities 都确定后,教练才有依据选训练重点。',
2083
+ description: '设置或读取比赛目标;profiles 查看带来源、样本范围和限制的比赛配置,profileId 可选用。样本不是赛场预测,不自动生成奖牌线或知识点清单。目标先于知识点。',
2087
2084
  parameters: {
2088
- action: { type: 'string', required: true, enum: ['read', 'set', 'clear'] },
2085
+ action: { type: 'string', required: true, enum: ['read', 'profiles', 'set', 'clear'] },
2089
2086
  expectedRevision: { type: 'integer', description: 'read 返回的 revision;首次 set 用 0。' },
2090
2087
  contest: { type: 'string', description: '比赛名称,例如西安区域赛' },
2091
2088
  date: { type: 'string', description: '比赛日期 YYYY-MM-DD' },
2092
2089
  result: { type: 'string', description: '目标结果,例如区域赛金牌' },
2093
2090
  teamMode: { type: 'string', description: 'solo 或 team' },
2094
2091
  weeklyHours: { type: 'number', description: '每周可用训练小时' },
2092
+ profileId: { type: 'string', description: 'profiles 返回的配置 id;空串清除。未选择时明确缺比赛样本依据。' },
2095
2093
  constraints: { type: 'object', additionalProperties: true, description: '可选时间约束,例如 weekday/saturday/sunday' },
2096
2094
  priorities: { type: 'array', description: '能力优先级 [{id, weight, reason}],weight 越大越优先', items: { type: 'object', additionalProperties: false, properties: {
2097
2095
  id: { type: 'string', required: true }, weight: { type: 'number', required: true }, reason: { type: 'string' },
@@ -2103,11 +2101,13 @@ function apply(ctx) {
2103
2101
  teamMode: { type: 'string', required: true }, weeklyHours: { type: 'number', required: true }, priorities: { type: 'array', required: true },
2104
2102
  updated: { type: 'string', required: true }, version: { type: 'integer', required: true },
2105
2103
  constraints: { type: 'object', required: true, additionalProperties: true }, text: { type: 'string', required: true },
2104
+ profileId: { type: 'string', required: true }, availableProfiles: { type: 'array', required: true },
2106
2105
  } }, render: (_a, v) => strategyText(v) },
2107
2106
  execute: (args) => {
2108
2107
  const v = strategy.target(args?.action || 'read', args ?? {})
2109
2108
  return { ...v, text: v.ok
2110
- ? `【比赛目标 · revision ${v.revision}】${v.contest} / ${v.date}\n目标:${v.result}\n模式:${v.teamMode};每周 ${v.weeklyHours} 小时\n能力重点:${v.priorities.map((p) => `${p.id}(${p.weight})`).join('、')}`
2109
+ ? args?.action === 'profiles' ? ['【比赛样本配置】', ...v.availableProfiles.map(p => `${p.id} · ${p.title}(${p.status})\n范围:${p.scope}\n取舍:${p.selectionGuidance.join(';')}\n限制:${p.limitations.join(';')}\n来源:${p.sources.map(s => `${s.title} ${s.url}`).join(';')}`)].join('\n\n')
2110
+ : `【比赛目标 · revision ${v.revision}】${v.contest} / ${v.date}\n目标:${v.result}\n模式:${v.teamMode};每周 ${v.weeklyHours} 小时\n样本配置:${v.profileId || '未选,先 profiles 核查'}\n能力重点:${v.priorities.map((p) => `${p.id}(${p.weight})`).join('、')}`
2111
2111
  : '' }
2112
2112
  },
2113
2113
  }))
@@ -2116,8 +2116,21 @@ function apply(ctx) {
2116
2116
  name: 'coach_vp_import',
2117
2117
  description: '导入 VP 或模拟赛记录。它保存比赛过程证据,让教练根据读题、耗时、提交和题目选择复盘,不再只看 CF rating。重复 eventId 会更新,不会重复堆积。',
2118
2118
  parameters: {
2119
- events: { type: 'array', required: true, items: { type: 'object', additionalProperties: true,
2120
- description: '每场含 eventId/contest/date/problems;problem 含 problemId/status/readMinutes/solveMinutes/attempts/competencies' } },
2119
+ events: { type: 'array', required: true, items: { type: 'object', additionalProperties: false,
2120
+ description: '每场含 eventId/contest/date/problems。AC 不证明独立性;失败不直接证明能力短板。测量由核对回放后的教练明确记录,同一场同一指标仅计一次。显式导入更新整场记录,请保留已有题目与注解。', properties: {
2121
+ eventId: { type: 'string', required: true }, contest: { type: 'string', required: true }, date: { type: 'string', required: true },
2122
+ durationMinutes: { type: 'integer' }, virtual: { type: 'boolean' }, teamMode: { type: 'string', enum: ['solo', 'team'] }, note: { type: 'string' },
2123
+ problems: { type: 'array', required: true, items: { type: 'object', additionalProperties: false, properties: {
2124
+ problemId: { type: 'string', required: true }, status: { type: 'string', required: true },
2125
+ readMinutes: { type: 'number' }, solveMinutes: { type: 'number', description: 'VP自动数据是从开赛到AC的经过时间,不是该题净用时。' },
2126
+ attempts: { type: 'integer' }, competencies: { type: 'array', items: { type: 'string' } }, note: { type: 'string' },
2127
+ independent: { type: 'boolean' }, occurredAt: { type: 'string' },
2128
+ } } },
2129
+ measurements: { type: 'array', items: { type: 'object', additionalProperties: false, properties: {
2130
+ competency: { type: 'string', required: true }, metric: { type: 'string', required: true }, value: { type: 'number', required: true },
2131
+ passed: { type: 'boolean', required: true, description: '是否对照明确条件验收通过;不能仅凭AC推断。' }, independent: { type: 'boolean' }, occurredAt: { type: 'string' },
2132
+ } } },
2133
+ } } },
2121
2134
  },
2122
2135
  output: { schema: { type: 'object', additionalProperties: false, properties: {
2123
2136
  ok: { type: 'boolean', required: true }, reject: { type: 'string', required: true }, imported: { type: 'integer', required: true },
@@ -2132,12 +2145,17 @@ function apply(ctx) {
2132
2145
 
2133
2146
  ctx.tools.register(defineTool({
2134
2147
  name: 'coach_postmortem',
2135
- description: '记录一场 VP 的赛后复盘。至少写能力问题、根因或下一步动作之一;复盘会影响 coach_focus 的优先级。',
2148
+ description: '记录 VP 复盘:findings 分 hypothesis(待确认)、confirmed(已核实短板)、resolved(已解决)。先 coach_curriculum assess 获取本场原始 VP 事实 id,结论必须引用真实 evidenceRefs。工具核对引用,不替代你核对代码/回放的因果判断;旧文字复盘只保留为观察。',
2136
2149
  parameters: {
2137
2150
  eventId: { type: 'string', required: true }, competencies: { type: 'array', items: { type: 'string' } },
2138
2151
  missedReads: { type: 'array', items: { type: 'string' } }, decisionErrors: { type: 'array', items: { type: 'string' } },
2139
2152
  teamIssues: { type: 'array', items: { type: 'string' } }, rootCauses: { type: 'array', items: { type: 'string' } },
2140
2153
  nextActions: { type: 'array', items: { type: 'string' } }, note: { type: 'string' }, date: { type: 'string' },
2154
+ findings: { type: 'array', description: '每能力最多一项结论;确认前需对照代码或回放。不能把 WA 直接等同于不会。', items: { type: 'object', additionalProperties: false, properties: {
2155
+ competency: { type: 'string', required: true }, status: { type: 'string', required: true, enum: ['hypothesis', 'confirmed', 'resolved'] },
2156
+ cause: { type: 'string', required: true, description: '至少10字,说明观察与推理;不能只重复判题结果。' },
2157
+ evidenceRefs: { type: 'array', required: true, items: { type: 'string' }, description: '本场真实 VP 事实 id,内容变化后旧引用失效。' },
2158
+ } } },
2141
2159
  },
2142
2160
  output: { schema: { type: 'object', additionalProperties: false, properties: {
2143
2161
  ok: { type: 'boolean', required: true }, reject: { type: 'string', required: true }, eventId: { type: 'string', required: true },
@@ -2151,17 +2169,23 @@ function apply(ctx) {
2151
2169
 
2152
2170
  ctx.tools.register(defineTool({
2153
2171
  name: 'coach_focus',
2154
- description: '根据比赛目标权重和 VP 复盘证据生成当前训练重点。它输出能力、比赛价值、为什么现在学、关联知识点和暂缓项;没有目标时拒绝猜方向。',
2172
+ description: '汇总目标、比赛样本、当前课程与近30天 VP 事实。成功不算问题;只有引用事实的 confirmed 结论增加短板计数。返回独立能力起点、待确认观察、已确认问题、验收缺口与来源。排序是取舍参考,不是掌握度;没有目标时拒绝猜方向。',
2155
2173
  parameters: {},
2156
2174
  output: { schema: { type: 'object', additionalProperties: false, properties: {
2157
2175
  ok: { type: 'boolean', required: true }, reject: { type: 'string', required: true }, contest: { type: 'string', required: true },
2158
2176
  deadline: { type: 'string', required: true }, focus: { type: 'array', required: true }, postponed: { type: 'array', required: true },
2159
2177
  evidenceEvents: { type: 'integer', required: true }, postmortems: { type: 'integer', required: true }, text: { type: 'string', required: true },
2178
+ profile: { type: 'object', required: true, additionalProperties: false, properties: {
2179
+ id: { type: 'string', required: true }, title: { type: 'string', required: true }, scope: { type: 'string', required: true }, status: { type: 'string', required: true },
2180
+ sources: { type: 'array', required: true }, competencyIds: { type: 'array', required: true }, selectionGuidance: { type: 'array', required: true }, limitations: { type: 'array', required: true },
2181
+ } }, daysRemaining: { type: 'integer', required: true },
2160
2182
  course: { type: 'object', required: true, additionalProperties: false, properties: {
2161
2183
  exists: { type: 'boolean', required: true }, revision: { type: 'integer', required: true },
2162
2184
  phaseId: { type: 'string', required: true }, phaseTitle: { type: 'string', required: true },
2163
2185
  focusNodes: { type: 'array', required: true, items: { type: 'string' } }, pendingNodes: { type: 'array', required: true, items: { type: 'string' } },
2164
2186
  phaseCompetencies: { type: 'array', required: true, items: { type: 'string' } },
2187
+ goal: { type: 'string', required: true }, outcome: { type: 'string', required: true },
2188
+ exitCheckResults: { type: 'array', required: true },
2165
2189
  focusExpired: { type: 'boolean', required: true }, missingFreshEvidence: { type: 'array', required: true, items: { type: 'string' } },
2166
2190
  } },
2167
2191
  } }, render: (_a, v) => strategyText(v) },
@@ -2177,13 +2201,17 @@ function apply(ctx) {
2177
2201
  exists: Boolean(snapshot.exists), revision: Number(snapshot.revision || 0), phaseId: String(snapshot.phaseId || ''),
2178
2202
  phaseTitle: String(snapshot.phaseTitle || ''), focusNodes: snapshot.focusNodes ?? [], pendingNodes: snapshot.pendingNodes ?? [],
2179
2203
  phaseCompetencies: snapshot.phaseCompetencies ?? [],
2204
+ goal: String(snapshot.goal || ''), outcome: String(snapshot.outcome || ''), exitCheckResults: snapshot.exitCheckResults ?? [],
2180
2205
  focusExpired: Boolean(snapshot.focusExpired), missingFreshEvidence: snapshot.missingFreshEvidence ?? [],
2181
2206
  }
2182
- return { ...v, course, text: v.ok ? [`【当前训练重点】${v.contest} · 截止 ${v.deadline}`,
2183
- ...v.focus.map((f, i) => `${i + 1}. ${f.title}(${f.id})\n 比赛价值:${f.contestUse}\n 为什么现在:${f.whyNow}\n 关联知识点:${f.nodes.join('、') || '需先定义'}\n 证据次数:${f.evidenceCount}`),
2207
+ return { ...v, course, text: v.ok ? [`【训练取舍依据】长期:${course.goal || '尚未建立'}\n本次:${v.contest} · ${v.deadline} · 剩余${v.daysRemaining}天`,
2208
+ `样本:${v.profile.title || '未选择'}(${v.profile.status})\n适用边界:${v.profile.limitations.join(';')}`,
2209
+ ...v.focus.map((f, i) => `${i + 1}. ${f.title}(${f.id})\n 比赛价值:${f.contestUse}\n 为什么现在:${f.whyNow}\n 关联知识点:${f.nodes.join('、') || '按比赛能力训练'}\n 近30天场次:确认短板${f.gapCount} / 独立起点${f.baselineCount} / 待确认${f.observationCount}`),
2184
2210
  course.exists ? `课程阶段:${course.phaseTitle}(${course.phaseId})\n课程主线:${course.focusNodes.join('、') || '能力证据驱动'}\n比赛能力:${course.phaseCompetencies.join('、') || '地图知识点'}` : '课程阶段:尚未建立,用 coach_curriculum assess/read/create 建立长期路线。',
2185
2211
  course.pendingNodes.length ? `课程未完成动作:${course.pendingNodes.join('、')}` : '',
2212
+ course.exitCheckResults.length ? `验收进展:${course.exitCheckResults.map(check => `${check.target}:${check.passed ? `已满足(${check.matchedEvents}次,跨${check.distinctDays}日)` : check.missing.join(',')}`).join(';')}` : '',
2186
2213
  v.postponed.length ? `暂缓:${v.postponed.join('、')}` : '没有暂缓项。',
2214
+ '教练据此解释取舍并给一个动作;排序不是掌握度,也不是奖牌概率。',
2187
2215
  ].join('\n') : '' }
2188
2216
  },
2189
2217
  }))
@@ -2197,6 +2225,7 @@ function apply(ctx) {
2197
2225
  id: { type: 'string', required: true }, title: { type: 'string', required: true }, contestUse: { type: 'string', required: true },
2198
2226
  nodes: { type: 'array', required: true }, appliesTo: { type: 'array', required: true }, extensions: { type: 'array', required: true }, notFor: { type: 'array', required: true },
2199
2227
  commonFailures: { type: 'array', required: true }, transferProblems: { type: 'array', required: true }, exitEvidence: { type: 'array', required: true },
2228
+ recognition: { type: 'array', required: true }, proofQuestions: { type: 'array', required: true }, sources: { type: 'array', required: true },
2200
2229
  text: { type: 'string', required: true },
2201
2230
  } }, render: (_a, v) => strategyText(v) },
2202
2231
  execute: (args) => {
@@ -2204,6 +2233,8 @@ function apply(ctx) {
2204
2233
  return { ...v, text: v.ok ? [`【范围卡片】${v.title}(${v.id})`, `比赛价值:${v.contestUse}`,
2205
2234
  `适用:${v.appliesTo.join('、')}`, `延伸:${v.extensions.join('、')}`, `不要用于:${v.notFor.join('、')}`,
2206
2235
  `常见错误:${v.commonFailures.join('、')}`, `迁移:${v.transferProblems.join('、')}`, `退出条件:${v.exitEvidence.join('、')}`,
2236
+ `识别信号:${v.recognition.join(';') || '先核对适用条件'}`, `解释与证明:${v.proofQuestions.join(';') || '先解释为何条件足够'}`,
2237
+ `来源:${v.sources.map(source => `${source.title} ${source.url}`).join(';') || '此卡尚无可核验来源'}`,
2207
2238
  ].join('\n') : '' }
2208
2239
  },
2209
2240
  }))
@@ -12,6 +12,10 @@ function date(value) {
12
12
  return Number.isFinite(parsed.getTime()) && parsed.toISOString().slice(0, 10) === d ? d : ''
13
13
  }
14
14
 
15
+ export const validOccurredAt = (value) => typeof value === 'string' &&
16
+ /^\d{4}-\d{2}-\d{2}T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d{1,3})?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/.test(value) &&
17
+ Boolean(date(value.slice(0, 10))) && Number.isFinite(Date.parse(value))
18
+
15
19
  // Sorted object keys make references insensitive to YAML/JavaScript field order.
16
20
  // Arrays keep their order, and the caller separately includes an event's position.
17
21
  function canonical(value, visiting = new Set()) {
@@ -38,6 +42,7 @@ function fingerprint(record) {
38
42
 
39
43
  function checkValid(record) {
40
44
  if (!object(record) || !date(record.date) || !['failed', 'overTime', 'passed'].includes(record.outcome)) return false
45
+ if (record.independent !== undefined && typeof record.independent !== 'boolean') return false
41
46
  // These fields were introduced at different times. Missing legacy fields are
42
47
  // compatible, but present malformed numbers cannot become trusted evidence.
43
48
  for (const key of ['score', 'maxScore', 'passScore', 'minutes', 'limit', 'verifiedAt', 'selfReported']) {
@@ -65,7 +70,7 @@ const practiceKey = (day, node, record, minutes) => JSON.stringify([
65
70
 
66
71
  /**
67
72
  * Read-only catalog of usable observations in PROGRESS and SCHEDULE.
68
- * Every returned field is a string. IDs contain both an event locator and a
73
+ * Performance fields keep their explicit types. IDs contain an event locator and a
69
74
  * canonical content hash, so references cannot silently resolve to edited data.
70
75
  */
71
76
  export function collectEvidence(progress, schedule = { days: {} }) {
@@ -73,12 +78,21 @@ export function collectEvidence(progress, schedule = { days: {} }) {
73
78
  const seenIds = new Set()
74
79
  const progressPractice = new Set()
75
80
  const add = (source, node, day, locator, eventId, signal, summary, record) => {
81
+ if (record.occurredAt !== undefined && !validOccurredAt(record.occurredAt)) return false
76
82
  const hash = fingerprint(record)
77
83
  if (!hash) return false
78
84
  const id = `${source}:${encodeURIComponent(node)}:${locator}:${hash}`
79
85
  if (!seenIds.has(id)) {
80
86
  seenIds.add(id)
81
- result.push({ id, eventId, source, node, date: day, signal, summary, fingerprint: hash })
87
+ const facts = {}
88
+ if (typeof record.independent === 'boolean') facts.independent = record.independent
89
+ if (record.occurredAt !== undefined) facts.occurredAt = record.occurredAt
90
+ const minutes = source === 'schedule' ? record.actualMin : record.minutes
91
+ if (positive(minutes)) facts.minutes = minutes
92
+ if (typeof record.metric === 'string' && record.metric.trim() && Number.isFinite(record.value)) {
93
+ facts.metric = record.metric.trim(); facts.value = record.value
94
+ }
95
+ result.push({ id, eventId, source, node, date: day, signal, summary, fingerprint: hash, ...facts })
82
96
  }
83
97
  return true
84
98
  }
@@ -1,12 +1,46 @@
1
+ import { validOccurredAt } from './curriculum-evidence.js'
2
+
1
3
  const clean = (value) => String(value ?? '').trim()
2
4
  const text = (value, field, phase) => {
3
5
  const result = clean(value)
4
6
  if (result.length < 10) throw new Error(`阶段 ${phase} 的 ${field} 需要至少 10 字的具体依据。`)
5
7
  return result
6
8
  }
7
- const coreKeys = ['id', 'title', 'outcome', 'nodes', 'competencies', 'exitCriteria', 'requiredVerified']
9
+ const coreKeys = ['id', 'title', 'outcome', 'nodes', 'competencies', 'exitCriteria', 'exitChecks', 'requiredVerified']
8
10
  export const corePhase = (phase) => Object.fromEntries(coreKeys.map((key) => [key, phase[key]]))
9
11
 
12
+ function normalizeExitChecks(input, phase) {
13
+ if (input === undefined) return []
14
+ if (!Array.isArray(input) || input.length > 100) throw new Error(`阶段 ${phase.id} 的 exitChecks 必须是最多 100 条的数组。`)
15
+ const targets = new Set()
16
+ return input.map((check) => {
17
+ const node = clean(check?.node), competency = clean(check?.competency)
18
+ if (Boolean(node) === Boolean(competency) || (node && !phase.nodes.includes(node)) || (competency && !phase.competencies.includes(competency))) throw new Error(`阶段 ${phase.id} 的 exitChecks 必须指定一个属于本阶段的 node 或 competency。`)
19
+ const target = `${node ? 'node' : 'competency'}:${node || competency}`
20
+ if (targets.has(target)) throw new Error(`阶段 ${phase.id} 的 exitChecks 目标不能重复:${node || competency}。`)
21
+ targets.add(target)
22
+ if (!Number.isInteger(check.minEvents) || check.minEvents < 1 || check.minEvents > 1000) throw new Error(`阶段 ${phase.id} 的 exitChecks.minEvents 应为 1–1000 的整数。`)
23
+ const result = { ...(node ? { node } : { competency }), minEvents: check.minEvents }
24
+ if (check.minDistinctDays !== undefined) {
25
+ if (!Number.isInteger(check.minDistinctDays) || check.minDistinctDays < 1 || check.minDistinctDays > check.minEvents) throw new Error(`阶段 ${phase.id} 的 minDistinctDays 应为 1–minEvents 的整数。`)
26
+ result.minDistinctDays = check.minDistinctDays
27
+ }
28
+ if (check.independent !== undefined) {
29
+ if (typeof check.independent !== 'boolean') throw new Error(`阶段 ${phase.id} 的 independent 必须是布尔值,不能从说明猜测。`)
30
+ result.independent = check.independent
31
+ }
32
+ if (check.maxMinutes !== undefined) {
33
+ if (!Number.isFinite(check.maxMinutes) || check.maxMinutes <= 0) throw new Error(`阶段 ${phase.id} 的 maxMinutes 必须为正数。`)
34
+ result.maxMinutes = check.maxMinutes
35
+ }
36
+ if (check.metric !== undefined || check.maxValue !== undefined) {
37
+ if (typeof check.metric !== 'string' || !check.metric.trim() || !Number.isFinite(check.maxValue)) throw new Error(`阶段 ${phase.id} 的 metric 与数值 maxValue 必须一起填写。`)
38
+ result.metric = check.metric.trim(); result.maxValue = check.maxValue
39
+ }
40
+ return result
41
+ })
42
+ }
43
+
10
44
  export function prerequisitesOf(nodes, byId) {
11
45
  const result = new Set()
12
46
  const done = new Set()
@@ -29,7 +63,7 @@ export function prerequisitesOf(nodes, byId) {
29
63
  return [...result].sort()
30
64
  }
31
65
 
32
- export function normalizePhases(input, byId, { legacy = false, completed = [] } = {}) {
66
+ export function normalizePhases(input, byId, { legacy = false, completed = [], allowMissingChecks = false } = {}) {
33
67
  if (!Array.isArray(input) || input.length < 1 || input.length > 30) throw new Error('课程需要 1–30 个阶段。')
34
68
  const ids = new Set()
35
69
  return input.map((phase, index) => {
@@ -42,6 +76,7 @@ export function normalizePhases(input, byId, { legacy = false, completed = [] }
42
76
  requiredVerified: Array.isArray(phase?.requiredVerified) ? phase.requiredVerified.map(clean)
43
77
  : (kind === 'training' || kind === 'legacy' ? [...(phase?.nodes ?? [])].map(clean) : []),
44
78
  }
79
+ p.exitChecks = normalizeExitChecks(phase?.exitChecks, p)
45
80
  if (!p.id || p.id.length > 80 || ids.has(p.id)) throw new Error('阶段 id 必须非空且唯一,最长 80 字符。')
46
81
  ids.add(p.id)
47
82
  if (!p.title || !p.outcome || !p.exitCriteria) throw new Error(`阶段 ${p.id} 缺标题、能力成果或验收条件。`)
@@ -52,7 +87,7 @@ export function normalizePhases(input, byId, { legacy = false, completed = [] }
52
87
  if (legacy) return { ...p, kind, finding: 'unknown', basis: '', goalContribution: '', priorityReason: '',
53
88
  estimatedHours: 0, evidenceRefs: [], uncertainty: '', diagnosticMethod: '', prerequisites }
54
89
  if (kind === 'legacy' && completed[index]?.kind === 'legacy' &&
55
- JSON.stringify(corePhase(completed[index])) === JSON.stringify(p)) return { ...completed[index], prerequisites }
90
+ JSON.stringify(corePhase(completed[index])) === JSON.stringify(corePhase(p))) return { ...completed[index], prerequisites }
56
91
  if (!['diagnostic', 'training', 'provisional'].includes(kind)) throw new Error(`阶段 ${p.id} 必须声明 kind=diagnostic/training/provisional;旧路线需补齐编排依据。`)
57
92
  const finding = clean(phase.finding)
58
93
  if (kind === 'training' ? !['gap', 'extension'].includes(finding) : finding !== 'unknown') throw new Error(`阶段 ${p.id}:训练需声明 gap 或 extension;诊断/待定只能声明 unknown。`)
@@ -60,6 +95,10 @@ export function normalizePhases(input, byId, { legacy = false, completed = [] }
60
95
  const estimatedHours = phase.estimatedHours
61
96
  if (!Number.isFinite(estimatedHours) || estimatedHours < 0 || estimatedHours > 10000 || (kind !== 'provisional' && estimatedHours === 0)) throw new Error(`阶段 ${p.id} 必须给正数 estimatedHours;待定阶段可用 0 表示未估算。`)
62
97
  if (kind === 'training' && p.nodes.some((node) => !p.requiredVerified.includes(node))) throw new Error(`训练阶段 ${p.id} 的节点必须全部列入 requiredVerified,不能用空验收绕过检测。`)
98
+ const preserved = completed[index] && JSON.stringify(corePhase(completed[index])) === JSON.stringify(corePhase(p))
99
+ if (kind === 'training' && !allowMissingChecks && !preserved &&
100
+ (p.nodes.some((node) => !p.exitChecks.some((check) => check.node === node)) ||
101
+ p.competencies.some((competency) => !p.exitChecks.some((check) => check.competency === competency)))) throw new Error(`训练阶段 ${p.id} 的 exitChecks 必须覆盖每个节点和能力;文字验收不能代替可检查条件。`)
63
102
  if (kind !== 'training' && p.requiredVerified.length) throw new Error(`诊断/待定阶段 ${p.id} 不以既有 verified 验收,请使用新诊断记录。`)
64
103
  return { ...p, kind, finding, estimatedHours, prerequisites,
65
104
  basis: text(phase.basis, 'basis(观察或待确认问题)', p.id),
@@ -91,6 +130,8 @@ export function validatePlan(phases, { byId, progress, catalog, current = 0 }) {
91
130
  if (!nodeMatch && !competencyMatch) throw new Error(`阶段 ${phase.id} 引用了无关的证据记录「${ref}」。`)
92
131
  }
93
132
  if (phase.kind !== 'training') continue
133
+ if (phase.nodes.some((node) => !phase.exitChecks.some((check) => check.node === node)) ||
134
+ phase.competencies.some((competency) => !phase.exitChecks.some((check) => check.competency === competency))) throw new Error(`阶段 ${phase.id} 尚缺结构化 exitChecks;旧路线请 revise 补齐,不会自动猜测验收次数。`)
94
135
  const signal = phase.finding === 'gap' ? 'gap' : 'baseline'
95
136
  const uncovered = phase.nodes.filter((node) => !phase.evidenceRefs.some((ref) => {
96
137
  const entry = records.get(ref)
@@ -111,12 +152,40 @@ export function validatePlan(phases, { byId, progress, catalog, current = 0 }) {
111
152
  }
112
153
  }
113
154
 
114
- export function missingFreshEvidence(phase, catalog, entryEvidenceRefs, enteredDate, entryEvidenceEvents = []) {
155
+ const validEvidenceDate = (date, enteredDate, today) => typeof date === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(date) &&
156
+ Number.isFinite(Date.parse(`${date}T12:00:00Z`)) && new Date(`${date}T12:00:00Z`).toISOString().slice(0, 10) === date &&
157
+ date >= enteredDate && date <= today
158
+ const freshTiming = (entry, enteredDate, today, enteredAt, currentAt) => validEvidenceDate(entry.date, enteredDate, today) &&
159
+ (entry.occurredAt === undefined || (validOccurredAt(entry.occurredAt) &&
160
+ (!enteredAt || Date.parse(entry.occurredAt) >= Date.parse(enteredAt)) &&
161
+ (!currentAt || Date.parse(entry.occurredAt) <= Date.parse(currentAt))))
162
+
163
+ export function evaluateExitChecks(phase, catalog, entryEvidenceRefs, enteredDate, entryEvidenceEvents = [], today = '9999-12-31', enteredAt = '', currentAt = '') {
164
+ const old = new Set(entryEvidenceRefs), oldEvents = new Set(entryEvidenceEvents)
165
+ return (phase.exitChecks ?? []).map((check) => {
166
+ const events = new Map()
167
+ for (const entry of catalog) {
168
+ if ((check.node ? entry.node !== check.node : entry.competency !== check.competency) || entry.signal !== 'baseline' ||
169
+ !entry.eventId || old.has(entry.id) || oldEvents.has(entry.eventId) || !freshTiming(entry, enteredDate, today, enteredAt, currentAt)) continue
170
+ if (check.independent !== undefined && entry.independent !== check.independent) continue
171
+ if (check.maxMinutes !== undefined && (!Number.isFinite(entry.minutes) || entry.minutes <= 0 || entry.minutes > check.maxMinutes)) continue
172
+ if (check.metric && (entry.metric !== check.metric || !Number.isFinite(entry.value) || entry.value > check.maxValue)) continue
173
+ events.set(entry.eventId, entry)
174
+ }
175
+ const matchedEvents = events.size, distinctDays = new Set([...events.values()].map((entry) => entry.date)).size
176
+ const missing = []
177
+ if (matchedEvents < check.minEvents) missing.push(`合格新事件 ${matchedEvents}/${check.minEvents}`)
178
+ if (distinctDays < (check.minDistinctDays ?? 1)) missing.push(`不同日期 ${distinctDays}/${check.minDistinctDays ?? 1}`)
179
+ return { ...check, target: check.node || check.competency, matchedEvents, distinctDays, passed: missing.length === 0, missing }
180
+ })
181
+ }
182
+
183
+ export function missingFreshEvidence(phase, catalog, entryEvidenceRefs, enteredDate, entryEvidenceEvents = [], today = '9999-12-31', enteredAt = '', currentAt = '') {
115
184
  const old = new Set(entryEvidenceRefs)
116
185
  const events = new Set(entryEvidenceEvents)
117
186
  const missingNodes = phase.nodes.filter((node) => !catalog.some((entry) => entry.node === node && !old.has(entry.id) &&
118
- !events.has(entry.eventId) && entry.date >= enteredDate && (phase.kind === 'diagnostic' || entry.signal === 'baseline')))
187
+ !events.has(entry.eventId) && freshTiming(entry, enteredDate, today, enteredAt, currentAt) && (phase.kind === 'diagnostic' || entry.signal === 'baseline')))
119
188
  const missingCompetencies = phase.competencies.filter((competency) => !catalog.some((entry) => entry.competency === competency && !old.has(entry.id) &&
120
- !events.has(entry.eventId) && entry.date >= enteredDate && (phase.kind === 'diagnostic' || entry.signal === 'baseline')))
189
+ !events.has(entry.eventId) && freshTiming(entry, enteredDate, today, enteredAt, currentAt) && (phase.kind === 'diagnostic' || entry.signal === 'baseline')))
121
190
  return [...missingNodes, ...missingCompetencies]
122
191
  }