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 +14 -5
- package/assets/knowledge/vp.html +47 -0
- package/assets/rules/coach-rules.md +1 -1
- package/dev/curriculum-check.mjs +20 -0
- package/dev/strategy-check.mjs +85 -2
- package/dev/vp-client-check.mjs +46 -0
- package/dev.sh +1 -1
- package/docs//351/225/277/346/234/237/350/257/276/347/250/213/346/234/272/345/210/266.md +7 -4
- package/index.js +236 -16
- package/lib/client.js +38 -0
- package/lib/curriculum-planning.js +15 -4
- package/lib/curriculum.js +32 -20
- package/lib/strategy.js +123 -14
- package/lib/vp-client.js +148 -0
- package/package.json +1 -1
- package/verify-shape.mjs +13 -0
package/lib/curriculum.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// 课程路线保存教学决定;进度和检测证据仍从 PROGRESS 读取。
|
|
2
|
-
import { readFileSync, writeFileSync, renameSync, unlinkSync } from 'node:fs'
|
|
2
|
+
import { copyFileSync, readFileSync, writeFileSync, renameSync, unlinkSync } from 'node:fs'
|
|
3
3
|
import { join } from 'node:path'
|
|
4
4
|
import { randomUUID } from 'node:crypto'
|
|
5
5
|
import { parse, stringify } from 'yaml'
|
|
@@ -17,13 +17,18 @@ function validDate(value) {
|
|
|
17
17
|
const d = new Date(`${value}T12:00:00Z`)
|
|
18
18
|
return Number.isFinite(d.getTime()) && d.toISOString().slice(0, 10) === value
|
|
19
19
|
}
|
|
20
|
-
export function createCurriculumStore({ dataDir, loadMap, loadProgress, loadSchedule = () => ({ days: {} }), now = () => new Date() }) {
|
|
20
|
+
export function createCurriculumStore({ dataDir, loadMap, loadProgress, loadSchedule = () => ({ days: {} }), loadStrategyEvidence = () => [], now = () => new Date() }) {
|
|
21
21
|
const path = join(dataDir, 'CURRICULUM.yaml')
|
|
22
|
-
const evidence = () =>
|
|
22
|
+
const evidence = () => [
|
|
23
|
+
...collectEvidence(loadProgress(), loadSchedule()).filter((entry) => loadMap().byId.has(entry.node)),
|
|
24
|
+
...loadStrategyEvidence().filter((entry) => entry && typeof entry.id === 'string' && typeof entry.competency === 'string'),
|
|
25
|
+
]
|
|
23
26
|
function enter(doc, catalog) {
|
|
24
27
|
doc.enteredAt = now().toISOString()
|
|
25
|
-
|
|
26
|
-
|
|
28
|
+
const phase = doc.phases[doc.current]
|
|
29
|
+
const inPhase = catalog.filter((entry) => phase.nodes.includes(entry.node) || phase.competencies.includes(entry.competency))
|
|
30
|
+
doc.entryEvidenceRefs = inPhase.map((entry) => entry.id)
|
|
31
|
+
doc.entryEvidenceEvents = inPhase.map((entry) => entry.eventId)
|
|
27
32
|
}
|
|
28
33
|
function budget(doc) {
|
|
29
34
|
const remainingEstimatedHours = doc.status === 'complete' ? 0 : doc.phases.slice(doc.current).reduce((sum, phase) => sum + phase.estimatedHours, 0)
|
|
@@ -75,7 +80,12 @@ export function createCurriculumStore({ dataDir, loadMap, loadProgress, loadSche
|
|
|
75
80
|
const temporary = `${path}.${randomUUID()}.tmp`
|
|
76
81
|
try {
|
|
77
82
|
writeFileSync(temporary, '# 课程路线;进度与检测证据仍以 PROGRESS.yaml 为准。\n' + stringify(doc, { lineWidth: 0 }), 'utf8')
|
|
78
|
-
renameSync(temporary, path)
|
|
83
|
+
try { renameSync(temporary, path) } catch (err) {
|
|
84
|
+
if (!['EPERM', 'EEXIST'].includes(err.code)) throw err
|
|
85
|
+
try { unlinkSync(path) } catch (removeErr) { if (removeErr.code !== 'ENOENT') throw err }
|
|
86
|
+
copyFileSync(temporary, path)
|
|
87
|
+
unlinkSync(temporary)
|
|
88
|
+
}
|
|
79
89
|
} finally {
|
|
80
90
|
try { unlinkSync(temporary) } catch (err) { if (err.code !== 'ENOENT') throw err }
|
|
81
91
|
}
|
|
@@ -84,7 +94,7 @@ export function createCurriculumStore({ dataDir, loadMap, loadProgress, loadSche
|
|
|
84
94
|
const empty = {
|
|
85
95
|
ok: true, reject: '', exists: false, revision: 0, status: 'none', goal: '', baseline: '',
|
|
86
96
|
weeklyHours: 0, deadline: '', phaseId: '', phaseTitle: '', outcome: '', exitCriteria: '',
|
|
87
|
-
focusNodes: [], focusUntil: '', focusExpired: false, requiredVerified: [], missingVerified: [],
|
|
97
|
+
focusNodes: [], phaseCompetencies: [], focusUntil: '', focusExpired: false, requiredVerified: [], missingVerified: [],
|
|
88
98
|
pendingNodes: [], roadmap: [], history: [], updated: '',
|
|
89
99
|
needsReview: false, planningWarnings: [], evidence: [], evidenceCount: 0, evidenceTruncated: false,
|
|
90
100
|
missingPrerequisites: [], missingFreshEvidence: [],
|
|
@@ -103,6 +113,7 @@ export function createCurriculumStore({ dataDir, loadMap, loadProgress, loadSche
|
|
|
103
113
|
baseline: doc.baseline, weeklyHours: doc.weeklyHours, deadline: doc.deadline,
|
|
104
114
|
phaseId: phase.id, phaseTitle: phase.title, outcome: phase.outcome, exitCriteria: phase.exitCriteria,
|
|
105
115
|
focusNodes: doc.focusNodes.length ? doc.focusNodes : phase.nodes,
|
|
116
|
+
phaseCompetencies: phase.competencies,
|
|
106
117
|
focusUntil: doc.focusUntil, focusExpired: Boolean(doc.focusUntil && doc.focusUntil < dateOf(now)),
|
|
107
118
|
requiredVerified: phase.requiredVerified,
|
|
108
119
|
missingVerified: phase.requiredVerified.filter((n) => progress.nodes?.[n]?.status !== 'verified'),
|
|
@@ -112,9 +123,9 @@ export function createCurriculumStore({ dataDir, loadMap, loadProgress, loadSche
|
|
|
112
123
|
history: doc.history.map(({ revision, action, reason, at, fromPhase, toPhase }) => ({ revision, action, reason, at, fromPhase, toPhase })),
|
|
113
124
|
updated: doc.updated,
|
|
114
125
|
needsReview: planningWarnings.length > 0, planningWarnings,
|
|
115
|
-
evidence: catalog.filter((entry) => phase.nodes.includes(entry.node)).slice(-200),
|
|
116
|
-
evidenceCount: catalog.filter((entry) => phase.nodes.includes(entry.node)).length,
|
|
117
|
-
evidenceTruncated: catalog.filter((entry) => phase.nodes.includes(entry.node)).length > 200,
|
|
126
|
+
evidence: catalog.filter((entry) => phase.nodes.includes(entry.node) || phase.competencies.includes(entry.competency)).slice(-200),
|
|
127
|
+
evidenceCount: catalog.filter((entry) => phase.nodes.includes(entry.node) || phase.competencies.includes(entry.competency)).length,
|
|
128
|
+
evidenceTruncated: catalog.filter((entry) => phase.nodes.includes(entry.node) || phase.competencies.includes(entry.competency)).length > 200,
|
|
118
129
|
missingPrerequisites,
|
|
119
130
|
missingFreshEvidence: doc.version === 2 ? missingFreshEvidence(phase, catalog, doc.entryEvidenceRefs, dateOf(() => new Date(doc.enteredAt)), doc.entryEvidenceEvents) : [...phase.nodes],
|
|
120
131
|
...budget(doc),
|
|
@@ -180,7 +191,7 @@ export function createCurriculumStore({ dataDir, loadMap, loadProgress, loadSche
|
|
|
180
191
|
throw new Error('当前诊断不能通过 revise 改成训练或删掉待诊断节点;先产生新记录,再 advance。')
|
|
181
192
|
}
|
|
182
193
|
doc.phases = phases
|
|
183
|
-
const workKeys = ['kind', 'nodes', 'outcome', 'exitCriteria', 'basis', 'finding', 'evidenceRefs']
|
|
194
|
+
const workKeys = ['kind', 'nodes', 'competencies', 'outcome', 'exitCriteria', 'basis', 'finding', 'evidenceRefs']
|
|
184
195
|
if (old.version === 1 || workKeys.some((key) => JSON.stringify(doc.phases[doc.current][key]) !== JSON.stringify(old.phases[old.current][key]))) enter(doc, catalog)
|
|
185
196
|
}
|
|
186
197
|
}
|
|
@@ -237,7 +248,8 @@ export function createCurriculumStore({ dataDir, loadMap, loadProgress, loadSche
|
|
|
237
248
|
return [
|
|
238
249
|
`- 长期目标:${state.goal};课程版本 ${state.revision}(${state.status})`,
|
|
239
250
|
`- 当前阶段:${state.phaseTitle}(${state.phaseId});能力成果:${state.outcome}`,
|
|
240
|
-
`- 主线节点:${state.focusNodes.join('、')}`,
|
|
251
|
+
`- 主线节点:${state.focusNodes.join('、') || '由比赛能力证据驱动'}`,
|
|
252
|
+
state.phaseCompetencies.length ? `- 阶段比赛能力:${state.phaseCompetencies.join('、')}` : '',
|
|
241
253
|
`- 阶段验收:${state.exitCriteria}`,
|
|
242
254
|
`- 阶段编排依据:${state.roadmap.find((phase) => phase.active)?.basis ?? ''}`,
|
|
243
255
|
...state.planningWarnings.map((warning) => `- 编排待核查:${warning}`),
|
|
@@ -255,7 +267,7 @@ export function createCurriculumStore({ dataDir, loadMap, loadProgress, loadSche
|
|
|
255
267
|
const stringList = { type: 'array', items: { type: 'string' } }
|
|
256
268
|
const phaseProperties = {
|
|
257
269
|
id: { type: 'string', required: true }, title: { type: 'string', required: true },
|
|
258
|
-
outcome: { type: 'string', required: true }, nodes:
|
|
270
|
+
outcome: { type: 'string', required: true }, nodes: stringList, competencies: stringList,
|
|
259
271
|
exitCriteria: { type: 'string', required: true }, requiredVerified: stringList,
|
|
260
272
|
kind: { type: 'string', required: true, enum: ['diagnostic', 'training', 'provisional', 'legacy'], description: '诊断/有证据训练/远期待定;legacy只用于保留已完成历史。' },
|
|
261
273
|
finding: { type: 'string', required: true, enum: ['gap', 'extension', 'unknown'] },
|
|
@@ -271,14 +283,14 @@ export function curriculumTool(store) {
|
|
|
271
283
|
const required = (type) => ({ type, required: true })
|
|
272
284
|
return {
|
|
273
285
|
name: 'coach_curriculum',
|
|
274
|
-
description: '按真实记录编排长期课程:assess
|
|
286
|
+
description: '按真实记录编排长期课程:assess取检测/判因/训练/VP的证据id;read查看路线;create建立;revise按证据调整;advance验收后推进。阶段可绑定地图节点,也可只绑定比赛能力(读题、决策、团队沟通);训练阶段必须有对应真实短板或进阶起点,并按显式前置与必验阶段排序;无依据先diagnostic,远期可provisional但不能直接执行。阶段需目标贡献、优先级理由、时间估算。写操作带expectedRevision和reason;诊断/训练验收都需进入阶段后的新事件,不能复用旧记录。',
|
|
275
287
|
parameters: {
|
|
276
288
|
action: { type: 'string', required: true, enum: ['assess', 'read', 'create', 'revise', 'advance'] },
|
|
277
289
|
evidenceNodes: { ...stringList, description: 'assess可按地图节点过滤;最多返回200条,截断时缩小节点范围。' },
|
|
278
290
|
expectedRevision: { type: 'integer', description: 'read 返回的版本;首次 create 为 0。' },
|
|
279
291
|
goal: { type: 'string', description: '长期能力目标。' }, baseline: { type: 'string', description: '已核实的起点和待确认信息,不能猜测。' },
|
|
280
292
|
weeklyHours: { type: 'number', description: '每周可用小时;0 表示未确认。' }, deadline: { type: 'string', description: '目标日期 YYYY-MM-DD,可留空。' },
|
|
281
|
-
phases: { type: 'array', description: '
|
|
293
|
+
phases: { type: 'array', description: '按证据与前置排序;阶段至少绑定一个地图节点或比赛能力;training地图节点必验、能力阶段用新 VP/复盘证据验收;diagnostic收集新记录;provisional保留远期假设。', items: { type: 'object', additionalProperties: false, properties: phaseProperties } },
|
|
282
294
|
focusNodes: { ...stringList, description: '当前阶段内的本周主线,空数组表示沿用阶段节点。' },
|
|
283
295
|
focusUntil: { type: 'string', description: '本周主线截止日期 YYYY-MM-DD,可留空;到期先复盘。' },
|
|
284
296
|
reason: { type: 'string', description: '建立/调整的依据;advance 时写验收证据及记录来源,至少 10 字。' },
|
|
@@ -289,16 +301,16 @@ export function curriculumTool(store) {
|
|
|
289
301
|
status: required('string'), goal: required('string'), baseline: required('string'), weeklyHours: required('number'),
|
|
290
302
|
deadline: required('string'), phaseId: required('string'), phaseTitle: required('string'), outcome: required('string'),
|
|
291
303
|
exitCriteria: required('string'), focusUntil: required('string'), focusExpired: required('boolean'), updated: required('string'),
|
|
292
|
-
|
|
304
|
+
focusNodes: { ...stringList, required: true }, phaseCompetencies: { ...stringList, required: true }, requiredVerified: { ...stringList, required: true },
|
|
293
305
|
missingVerified: { ...stringList, required: true }, pendingNodes: { ...stringList, required: true },
|
|
294
306
|
needsReview: required('boolean'), planningWarnings: { ...stringList, required: true },
|
|
295
307
|
missingPrerequisites: { ...stringList, required: true }, missingFreshEvidence: { ...stringList, required: true },
|
|
296
308
|
evidenceCount: required('integer'), evidenceTruncated: required('boolean'),
|
|
297
309
|
remainingEstimatedHours: required('number'), estimatedWeeks: required('number'), budgetWarnings: { ...stringList, required: true },
|
|
298
310
|
evidence: { type: 'array', required: true, items: { type: 'object', additionalProperties: false, properties:
|
|
299
|
-
Object.fromEntries(['id', 'eventId', 'source', 'node', 'date', 'signal', 'summary', 'fingerprint'].map((key) => [key, required('string')])) } },
|
|
311
|
+
{ ...Object.fromEntries(['id', 'eventId', 'source', 'node', 'date', 'signal', 'summary', 'fingerprint'].map((key) => [key, required('string')])), competency: { type: 'string' } } } },
|
|
300
312
|
roadmap: { type: 'array', required: true, items: { type: 'object', additionalProperties: false, properties: {
|
|
301
|
-
...phaseProperties, requiredVerified: { ...stringList, required: true }, position: required('integer'), active: required('boolean'),
|
|
313
|
+
...phaseProperties, nodes: { ...stringList, required: true }, competencies: { ...stringList, required: true }, requiredVerified: { ...stringList, required: true }, position: required('integer'), active: required('boolean'),
|
|
302
314
|
prerequisites: { ...stringList, required: true },
|
|
303
315
|
} } },
|
|
304
316
|
history: { type: 'array', required: true, items: { type: 'object', additionalProperties: false, properties: {
|
|
@@ -308,14 +320,14 @@ export function curriculumTool(store) {
|
|
|
308
320
|
} },
|
|
309
321
|
render: (args, state) => [{ type: 'text', text: !state.ok ? `❌ 课程未更新:${state.reject}`
|
|
310
322
|
: args?.action === 'assess' ? ['【实际编排依据】',
|
|
311
|
-
...state.evidence.map((entry) => `${entry.id}\n ${entry.date} ${entry.node} · ${entry.signal} · ${entry.summary}`),
|
|
323
|
+
...state.evidence.map((entry) => `${entry.id}\n ${entry.date} ${entry.node || entry.competency} · ${entry.signal} · ${entry.summary}`),
|
|
312
324
|
state.evidenceCount ? `共${state.evidenceCount}条${state.evidenceTruncated ? ',仅返回最后200条;按evidenceNodes缩小范围' : ''}` : '没有可用记录。先建立诊断阶段,不凭rating或状态断言短板。',
|
|
313
325
|
...state.planningWarnings,
|
|
314
326
|
].join('\n')
|
|
315
327
|
: !state.exists ? '尚无长期课程。先assess核查真实记录;缺依据先diagnostic,未来假设用provisional。'
|
|
316
328
|
: [`【长期课程 · 版本 ${state.revision} · ${state.status}】${state.goal}`,
|
|
317
329
|
...state.roadmap.map((p) => `${p.active ? '▶ ' : ''}${p.position}. ${p.title} [${p.kind}]:${p.outcome}\n 依据:${p.basis}\n 排序:${p.priorityReason};预计${p.estimatedHours}小时`),
|
|
318
|
-
`当前主线:${state.focusNodes.join('、')}`, `验收:${state.exitCriteria}`,
|
|
330
|
+
`当前主线:${state.focusNodes.join('、') || '能力证据驱动'}`, state.phaseCompetencies.length ? `阶段比赛能力:${state.phaseCompetencies.join('、')}` : '', `验收:${state.exitCriteria}`,
|
|
319
331
|
state.missingVerified.length ? `必验缺口:${state.missingVerified.join('、')}` : '必验节点已有记录;仍需判断能力成果是否达到,不能自动推进。',
|
|
320
332
|
state.pendingNodes.length ? `先完成当前单元:${state.pendingNodes.join('、')}` : '',
|
|
321
333
|
state.focusExpired ? '本周主线到期,先复盘并修订。' : '',
|
package/lib/strategy.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Goal-driven strategy layer for acmer-coach.
|
|
2
2
|
// It stores contest targets, VP evidence and postmortems separately from
|
|
3
3
|
// PROGRESS.yaml so the existing knowledge workflow keeps its meaning.
|
|
4
|
-
import {
|
|
4
|
+
import { copyFileSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
5
5
|
import { join } from 'node:path'
|
|
6
6
|
import { parse, stringify } from 'yaml'
|
|
7
7
|
|
|
@@ -29,8 +29,22 @@ function readYaml(path, fallback) {
|
|
|
29
29
|
|
|
30
30
|
function writeYaml(path, header, value) {
|
|
31
31
|
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
let moved = false
|
|
33
|
+
try {
|
|
34
|
+
writeFileSync(tmp, header + stringify(value, { lineWidth: 0 }), 'utf8')
|
|
35
|
+
try { renameSync(tmp, path) } catch (err) {
|
|
36
|
+
if (!['EPERM', 'EEXIST'].includes(err.code)) throw err
|
|
37
|
+
// Windows sandbox may refuse rename over an existing file; keep the complete tmp file and replace by copy.
|
|
38
|
+
try { unlinkSync(path) } catch (removeErr) { if (removeErr.code !== 'ENOENT') throw err }
|
|
39
|
+
copyFileSync(tmp, path)
|
|
40
|
+
unlinkSync(tmp)
|
|
41
|
+
}
|
|
42
|
+
moved = true
|
|
43
|
+
} finally {
|
|
44
|
+
if (!moved) {
|
|
45
|
+
try { unlinkSync(tmp) } catch { /* best effort cleanup */ }
|
|
46
|
+
}
|
|
47
|
+
}
|
|
34
48
|
}
|
|
35
49
|
|
|
36
50
|
function normalizePriority(value) {
|
|
@@ -94,14 +108,46 @@ function normalizePostmortem(value) {
|
|
|
94
108
|
export function createStrategyStore({ dataDir, competenciesPath, now = () => new Date() }) {
|
|
95
109
|
const targetPath = join(dataDir, 'TARGET.yaml')
|
|
96
110
|
const vpPath = join(dataDir, 'VP_EVENTS.yaml')
|
|
97
|
-
const
|
|
98
|
-
|
|
111
|
+
const validateTarget = (doc) => {
|
|
112
|
+
const errors = []
|
|
113
|
+
if (doc.version !== 1) errors.push('version 必须是 1')
|
|
114
|
+
if (!Number.isInteger(doc.revision) || doc.revision < 0) errors.push('revision 必须是非负整数')
|
|
115
|
+
if (!Array.isArray(doc.priorities)) errors.push('priorities 必须是数组')
|
|
116
|
+
else if (doc.priorities.some((item) => !normalizePriority(item))) errors.push('priorities 含有无效的 id/weight')
|
|
117
|
+
if (!doc.constraints || typeof doc.constraints !== 'object' || Array.isArray(doc.constraints)) errors.push('constraints 必须是对象')
|
|
118
|
+
return errors
|
|
119
|
+
}
|
|
120
|
+
const validateVp = (doc) => {
|
|
121
|
+
const errors = []
|
|
122
|
+
if (doc.version !== 1) errors.push('version 必须是 1')
|
|
123
|
+
if (!Number.isInteger(doc.revision) || doc.revision < 0) errors.push('revision 必须是非负整数')
|
|
124
|
+
if (!Array.isArray(doc.events)) errors.push('events 必须是数组')
|
|
125
|
+
else if (doc.events.some((item) => !normalizeEvent(item))) errors.push('events 含有无效记录')
|
|
126
|
+
if (!Array.isArray(doc.postmortems)) errors.push('postmortems 必须是数组')
|
|
127
|
+
else if (doc.postmortems.some((item) => !normalizePostmortem(item))) errors.push('postmortems 含有无效记录')
|
|
128
|
+
return errors
|
|
129
|
+
}
|
|
130
|
+
const loadTarget = () => {
|
|
131
|
+
const doc = { ...emptyTarget(), ...readYaml(targetPath, emptyTarget) }
|
|
132
|
+
const errors = validateTarget(doc)
|
|
133
|
+
if (errors.length) throw new Error(`TARGET.yaml 数据无效:${errors.join(';')}`)
|
|
134
|
+
return doc
|
|
135
|
+
}
|
|
136
|
+
const loadVp = () => {
|
|
137
|
+
const doc = { ...emptyVp(), ...readYaml(vpPath, emptyVp) }
|
|
138
|
+
const errors = validateVp(doc)
|
|
139
|
+
if (errors.length) throw new Error(`VP_EVENTS.yaml 数据无效:${errors.join(';')}`)
|
|
140
|
+
return doc
|
|
141
|
+
}
|
|
99
142
|
const saveTarget = (doc) => writeYaml(targetPath, '# TARGET.yaml — 比赛目标与能力优先级\n', doc)
|
|
100
143
|
const saveVp = (doc) => writeYaml(vpPath, '# VP_EVENTS.yaml — VP 记录与赛后复盘\n', doc)
|
|
101
144
|
const loadCompetencies = () => readYaml(competenciesPath, () => ({ version: 1, competencies: [] })).competencies ?? []
|
|
102
145
|
|
|
103
146
|
function target(action = 'read', args = {}) {
|
|
104
|
-
|
|
147
|
+
let old
|
|
148
|
+
try { old = loadTarget() } catch (err) {
|
|
149
|
+
return { ok: false, reject: err.message, ...emptyTarget() }
|
|
150
|
+
}
|
|
105
151
|
if (action === 'read') return { ok: true, reject: '', ...old }
|
|
106
152
|
if (!['set', 'clear'].includes(action)) return { ok: false, reject: 'action 只能是 read/set/clear。', ...old }
|
|
107
153
|
if (args.expectedRevision !== old.revision) return { ok: false, reject: '目标版本已变化;先 read,再按当前 revision 更新。', ...old }
|
|
@@ -114,7 +160,9 @@ export function createStrategyStore({ dataDir, competenciesPath, now = () => new
|
|
|
114
160
|
if (!clean(args.contest) || !isoDay(date) || !clean(args.result)) return { ok: false, reject: 'set 必须提供 contest、真实 YYYY-MM-DD 的 date 和 result。', ...old }
|
|
115
161
|
const weeklyHours = Number(args.weeklyHours)
|
|
116
162
|
if (!Number.isFinite(weeklyHours) || weeklyHours <= 0 || weeklyHours > 168) return { ok: false, reject: 'weeklyHours 必须在 0 和 168 之间。', ...old }
|
|
117
|
-
const
|
|
163
|
+
const rawPriorities = asArray(args.priorities)
|
|
164
|
+
const priorities = rawPriorities.map(normalizePriority).filter(Boolean)
|
|
165
|
+
if (rawPriorities.length !== priorities.length) return { ok: false, reject: 'priorities 中有无效项;每项都要提供正数 weight 和非空 id。', ...old }
|
|
118
166
|
if (!priorities.length) return { ok: false, reject: '至少给一个能力优先级:id + weight。', ...old }
|
|
119
167
|
const known = new Set(loadCompetencies().map((item) => item.id))
|
|
120
168
|
const unknown = priorities.map((item) => item.id).filter((id) => !known.has(id))
|
|
@@ -132,13 +180,19 @@ export function createStrategyStore({ dataDir, competenciesPath, now = () => new
|
|
|
132
180
|
}
|
|
133
181
|
|
|
134
182
|
function vpImport(events = []) {
|
|
135
|
-
|
|
136
|
-
|
|
183
|
+
let old
|
|
184
|
+
try { old = loadVp() } catch (err) {
|
|
185
|
+
return { ok: false, reject: err.message, imported: 0, total: 0, revision: 0, events: [], invalid: [] }
|
|
186
|
+
}
|
|
187
|
+
if (!Array.isArray(events) || !events.length) return { ok: false, reject: '至少导入一场含题目记录的 VP;空数组不会修改现有数据。', imported: 0, total: old.events.length, revision: old.revision, events: old.events, invalid: ['events 为空'] }
|
|
188
|
+
const normalized = events.map(normalizeEvent)
|
|
189
|
+
const invalid = normalized.map((event, index) => event ? '' : `第 ${index + 1} 条 VP 记录缺 eventId/contest/date/problems`).filter(Boolean)
|
|
190
|
+
if (invalid.length) return { ok: false, reject: 'VP 导入被拒绝:' + invalid.join(';'), imported: 0, total: old.events.length, revision: old.revision, events: old.events, invalid }
|
|
137
191
|
const existing = new Map(old.events.map((event) => [event.eventId, event]))
|
|
138
192
|
for (const event of normalized) existing.set(event.eventId, event)
|
|
139
193
|
const next = { ...old, events: [...existing.values()].sort((a, b) => `${a.date}|${a.eventId}`.localeCompare(`${b.date}|${b.eventId}`)), revision: old.revision + 1, updated: now().toISOString() }
|
|
140
194
|
saveVp(next)
|
|
141
|
-
return { ok: true, reject: '', imported: normalized.length, total: next.events.length, revision: next.revision, events: next.events }
|
|
195
|
+
return { ok: true, reject: '', imported: normalized.length, total: next.events.length, revision: next.revision, events: next.events, invalid: [] }
|
|
142
196
|
}
|
|
143
197
|
|
|
144
198
|
function postmortem(value) {
|
|
@@ -155,8 +209,16 @@ export function createStrategyStore({ dataDir, competenciesPath, now = () => new
|
|
|
155
209
|
}
|
|
156
210
|
|
|
157
211
|
function focus() {
|
|
158
|
-
|
|
159
|
-
|
|
212
|
+
let targetDoc, vp
|
|
213
|
+
try {
|
|
214
|
+
targetDoc = loadTarget()
|
|
215
|
+
vp = loadVp()
|
|
216
|
+
} catch (err) {
|
|
217
|
+
return {
|
|
218
|
+
ok: false, reject: err.message, contest: '', deadline: '', focus: [], postponed: [],
|
|
219
|
+
evidenceEvents: 0, postmortems: 0,
|
|
220
|
+
}
|
|
221
|
+
}
|
|
160
222
|
const catalog = loadCompetencies()
|
|
161
223
|
if (!targetDoc.contest) return {
|
|
162
224
|
ok: false, reject: '还没有比赛目标,先用 coach_target set。', contest: '', deadline: '',
|
|
@@ -164,9 +226,15 @@ export function createStrategyStore({ dataDir, competenciesPath, now = () => new
|
|
|
164
226
|
}
|
|
165
227
|
const postmortemHits = new Map()
|
|
166
228
|
for (const record of vp.postmortems) for (const id of [...record.competencies, ...record.rootCauses]) postmortemHits.set(id, (postmortemHits.get(id) ?? 0) + 1)
|
|
229
|
+
const problemHits = new Map()
|
|
230
|
+
for (const event of vp.events) {
|
|
231
|
+
for (const id of new Set(event.problems.flatMap((problem) => problem.competencies))) {
|
|
232
|
+
problemHits.set(id, (problemHits.get(id) ?? 0) + 1)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
167
235
|
const focus = targetDoc.priorities.map((priority) => {
|
|
168
236
|
const meta = catalog.find((item) => item.id === priority.id) ?? { id: priority.id, title: priority.id, contestUse: '暂无比赛能力卡片,先补充定义。', nodes: [] }
|
|
169
|
-
const evidence = postmortemHits.get(priority.id) ?? 0
|
|
237
|
+
const evidence = (postmortemHits.get(priority.id) ?? 0) + (problemHits.get(priority.id) ?? 0)
|
|
170
238
|
const urgency = Math.max(1, 30 - Math.floor((Date.parse(`${targetDoc.date}T12:00:00Z`) - Date.now()) / 86400000))
|
|
171
239
|
const score = Math.round(priority.weight * (1 + evidence) * urgency / 10) / 10
|
|
172
240
|
return { id: priority.id, title: meta.title ?? priority.id, score, evidenceCount: evidence, contestUse: meta.contestUse ?? '', nodes: meta.nodes ?? [], whyNow: evidence ? `最近复盘出现 ${evidence} 次相关问题;优先修复它。` : (priority.reason || '它是目标中的高权重能力,目前缺少 VP 证据。') }
|
|
@@ -181,6 +249,47 @@ export function createStrategyStore({ dataDir, competenciesPath, now = () => new
|
|
|
181
249
|
return { ok: false, reject: `「${nodeOrId}」还没有范围卡片,不能凭空编造适用范围;先补充 COMPETENCIES.yaml。`, found: false, id: nodeOrId, title: nodeOrId, contestUse: '', nodes: [], appliesTo: [], extensions: [], notFor: [], commonFailures: [], transferProblems: [], exitEvidence: [] }
|
|
182
250
|
}
|
|
183
251
|
|
|
252
|
+
function route(nodeOrId, { purpose = 'progress', courseBound = false } = {}) {
|
|
253
|
+
const node = clean(nodeOrId)
|
|
254
|
+
let targetDoc
|
|
255
|
+
try { targetDoc = loadTarget() } catch (err) { return { ok: false, reject: err.message, node, purpose, courseBound: Boolean(courseBound), targetAligned: false, competencyIds: [] } }
|
|
256
|
+
const base = { node, purpose, courseBound: Boolean(courseBound), targetAligned: true, competencyIds: [] }
|
|
257
|
+
if (!targetDoc.contest || purpose !== 'progress' || courseBound) return { ok: true, reject: '', ...base }
|
|
258
|
+
const catalog = loadCompetencies()
|
|
259
|
+
const priorityIds = new Set(targetDoc.priorities.map((item) => item.id))
|
|
260
|
+
const current = focus()
|
|
261
|
+
const currentIds = new Set((current.focus ?? []).map((item) => item.id))
|
|
262
|
+
const targetCards = catalog.filter((item) => priorityIds.has(item.id) && item.nodes?.includes(node))
|
|
263
|
+
const currentCards = targetCards.filter((item) => currentIds.has(item.id))
|
|
264
|
+
const mappedTarget = catalog.filter((item) => priorityIds.has(item.id) && item.nodes?.length)
|
|
265
|
+
if (mappedTarget.length && !currentCards.length) {
|
|
266
|
+
return { ok: false, reject: `「${node}」不服务当前比赛重点;当前重点是 ${current.focus.map((item) => item.id).join('、')}。如果这是课程主线,请带 curriculumRevision;如果是补漏/复习,请声明 purpose 和 routeReason。`, ...base, targetAligned: false, competencyIds: targetCards.map((item) => item.id) }
|
|
267
|
+
}
|
|
268
|
+
return { ok: true, reject: '', ...base, targetAligned: currentCards.length > 0, competencyIds: currentCards.map((item) => item.id) }
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function evidence() {
|
|
272
|
+
try {
|
|
273
|
+
const vp = loadVp()
|
|
274
|
+
const result = []
|
|
275
|
+
const baselineStatuses = new Set(['AC', 'OK', 'ACCEPTED', 'SOLVED'])
|
|
276
|
+
for (const event of vp.events) for (const problem of event.problems) {
|
|
277
|
+
const signal = baselineStatuses.has(problem.status) ? 'baseline' : 'gap'
|
|
278
|
+
for (const competency of new Set(problem.competencies)) {
|
|
279
|
+
const id = `vp:${event.eventId}:${problem.problemId}:${competency}`
|
|
280
|
+
result.push({ id, eventId: event.eventId, source: 'vp', node: '', competency,
|
|
281
|
+
date: event.date, signal, summary: `${problem.problemId} ${problem.status} · ${competency}`, fingerprint: id })
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
for (const record of vp.postmortems) for (const competency of new Set(record.competencies)) {
|
|
285
|
+
const id = `postmortem:${record.eventId}:${competency}`
|
|
286
|
+
result.push({ id, eventId: record.eventId, source: 'postmortem', node: '', competency,
|
|
287
|
+
date: record.date, signal: 'gap', summary: `复盘指出 ${competency}`, fingerprint: id })
|
|
288
|
+
}
|
|
289
|
+
return { ok: true, reject: '', evidence: result }
|
|
290
|
+
} catch (err) { return { ok: false, reject: err.message, evidence: [] } }
|
|
291
|
+
}
|
|
292
|
+
|
|
184
293
|
function context() {
|
|
185
294
|
const t = loadTarget()
|
|
186
295
|
if (!t.contest) return '- 比赛目标:尚未建立;先用 coach_target set,不要把 rating 当成长期目标。'
|
|
@@ -194,7 +303,7 @@ export function createStrategyStore({ dataDir, competenciesPath, now = () => new
|
|
|
194
303
|
].filter(Boolean).join('\n')
|
|
195
304
|
}
|
|
196
305
|
|
|
197
|
-
return { target, vpImport, postmortem, focus, scope, context, loadTarget, loadVp, loadCompetencies }
|
|
306
|
+
return { target, vpImport, postmortem, focus, scope, route, evidence, context, loadTarget, loadVp, loadCompetencies }
|
|
198
307
|
}
|
|
199
308
|
|
|
200
309
|
export const normalizeVpEvent = normalizeEvent
|
package/lib/vp-client.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { chmodSync, copyFileSync, mkdirSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
const DEFAULT_BASE_URL = 'https://xiaojing.run'
|
|
5
|
+
|
|
6
|
+
const cleanBaseUrl = (value) => {
|
|
7
|
+
const url = new URL(String(value || DEFAULT_BASE_URL))
|
|
8
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
|
|
9
|
+
throw new Error('VP baseUrl 必须是干净的 http/https 地址')
|
|
10
|
+
}
|
|
11
|
+
return url.toString().replace(/\/$/, '')
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function safeAccount(value) {
|
|
15
|
+
if (!value || typeof value !== 'object' || !Number.isInteger(Number(value.id))) throw new Error('VP 返回了无效账号')
|
|
16
|
+
return { id: Number(value.id), handle: String(value.handle || ''), display_name: String(value.display_name || value.handle || '') }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function atomicJson(path, value, mode = 0o600) {
|
|
20
|
+
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`
|
|
21
|
+
writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, 'utf8')
|
|
22
|
+
try { chmodSync(tmp, mode) } catch { /* Windows 没有 Unix mode,不影响原子写 */ }
|
|
23
|
+
try { renameSync(tmp, path) } catch (err) {
|
|
24
|
+
if (err.code !== 'EPERM' && err.code !== 'EEXIST') throw err
|
|
25
|
+
try { unlinkSync(path) } catch (removeErr) { if (removeErr.code !== 'ENOENT') throw err }
|
|
26
|
+
copyFileSync(tmp, path)
|
|
27
|
+
unlinkSync(tmp)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function createVpClient({ dataDir, fetchImpl = globalThis.fetch, now = () => new Date() } = {}) {
|
|
32
|
+
if (typeof fetchImpl !== 'function') throw new Error('VP 客户端需要 fetch')
|
|
33
|
+
const root = join(dataDir, 'vp-accounts')
|
|
34
|
+
const registryPath = join(dataDir, 'VP_ACCOUNTS.json')
|
|
35
|
+
|
|
36
|
+
const loadRegistry = () => {
|
|
37
|
+
try {
|
|
38
|
+
const doc = JSON.parse(readFileSync(registryPath, 'utf8'))
|
|
39
|
+
return doc && typeof doc === 'object' ? doc : { activeAccountId: '', accounts: {} }
|
|
40
|
+
} catch (err) {
|
|
41
|
+
if (err.code === 'ENOENT') return { version: 1, activeAccountId: '', accounts: {} }
|
|
42
|
+
throw err
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const saveRegistry = (value) => atomicJson(registryPath, { version: 1, ...value })
|
|
46
|
+
const accountDir = (id) => join(root, String(id))
|
|
47
|
+
const tokenPath = (id) => join(accountDir(id), 'TOKEN.json')
|
|
48
|
+
const loadToken = (id) => JSON.parse(readFileSync(tokenPath(id), 'utf8')).access_token
|
|
49
|
+
const active = () => {
|
|
50
|
+
const registry = loadRegistry()
|
|
51
|
+
const id = Number(registry.activeAccountId || 0)
|
|
52
|
+
if (!id) return null
|
|
53
|
+
const meta = registry.accounts?.[String(id)]
|
|
54
|
+
if (!meta) return null
|
|
55
|
+
return { id, account: safeAccount(meta.account), baseUrl: cleanBaseUrl(meta.baseUrl || DEFAULT_BASE_URL) }
|
|
56
|
+
}
|
|
57
|
+
const request = async (baseUrl, path, { token, method = 'GET', body } = {}) => {
|
|
58
|
+
const response = await fetchImpl(`${cleanBaseUrl(baseUrl)}${path}`, {
|
|
59
|
+
method, headers: { accept: 'application/json', ...(body ? { 'content-type': 'application/json' } : {}), ...(token ? { authorization: `Bearer ${token}` } : {}) },
|
|
60
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
61
|
+
})
|
|
62
|
+
let payload
|
|
63
|
+
try { payload = await response.json() } catch { payload = { detail: await response.text() } }
|
|
64
|
+
if (!response.ok) throw new Error(`VP ${response.status}: ${payload?.detail || payload?.message || '请求失败'}`)
|
|
65
|
+
return payload
|
|
66
|
+
}
|
|
67
|
+
const requireActive = () => {
|
|
68
|
+
const current = active()
|
|
69
|
+
if (!current) throw new Error('还没有连接 VP;先调用 coach_vp_connect start/finish')
|
|
70
|
+
return { ...current, token: loadToken(current.id) }
|
|
71
|
+
}
|
|
72
|
+
const verifyAccount = (current, value) => {
|
|
73
|
+
const returned = safeAccount(value)
|
|
74
|
+
if (returned.id !== current.id) throw new Error(`VP account mismatch: 当前是 ${current.id},接口返回 ${returned.id}`)
|
|
75
|
+
return returned
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
async start({ baseUrl = DEFAULT_BASE_URL } = {}) {
|
|
80
|
+
const clean = cleanBaseUrl(baseUrl)
|
|
81
|
+
const result = await request(clean, '/api/coach/auth/start', { method: 'POST', body: {
|
|
82
|
+
redirect_uri: 'http://127.0.0.1:8765/coach/callback',
|
|
83
|
+
scopes: ['profile', 'contests', 'submissions', 'replay'],
|
|
84
|
+
} })
|
|
85
|
+
return { ok: true, requestId: result.request_id, authorizeUrl: result.authorize_url, expiresAt: result.expires_at, baseUrl: clean }
|
|
86
|
+
},
|
|
87
|
+
async finish({ code, baseUrl = DEFAULT_BASE_URL } = {}) {
|
|
88
|
+
const clean = cleanBaseUrl(baseUrl)
|
|
89
|
+
const result = await request(clean, '/api/coach/auth/exchange', { method: 'POST', body: { code: String(code || '') } })
|
|
90
|
+
const account = safeAccount(result.user)
|
|
91
|
+
const dir = accountDir(account.id)
|
|
92
|
+
mkdirSync(dir, { recursive: true })
|
|
93
|
+
atomicJson(tokenPath(account.id), { access_token: String(result.access_token), saved_at: now().toISOString() })
|
|
94
|
+
const registry = loadRegistry()
|
|
95
|
+
registry.accounts = registry.accounts || {}
|
|
96
|
+
registry.accounts[String(account.id)] = { account, baseUrl: clean, lastSyncAt: registry.accounts[String(account.id)]?.lastSyncAt || '' }
|
|
97
|
+
registry.activeAccountId = account.id
|
|
98
|
+
saveRegistry(registry)
|
|
99
|
+
return { ok: true, account, activeAccountId: account.id }
|
|
100
|
+
},
|
|
101
|
+
async status() {
|
|
102
|
+
const current = active()
|
|
103
|
+
if (!current) return { connected: false, account: null, activeAccountId: null }
|
|
104
|
+
try {
|
|
105
|
+
const remote = await request(current.baseUrl, '/api/coach/me', { token: loadToken(current.id) })
|
|
106
|
+
const account = verifyAccount(current, remote.user)
|
|
107
|
+
return { connected: true, account, activeAccountId: current.id, baseUrl: current.baseUrl, readOnly: remote.read_only !== false }
|
|
108
|
+
} catch (err) {
|
|
109
|
+
return { connected: false, account: current.account, activeAccountId: current.id, error: err.message }
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
async sync({ limit = 200, includeProfile = true } = {}) {
|
|
113
|
+
const current = requireActive()
|
|
114
|
+
const registry = loadRegistry()
|
|
115
|
+
const accountMeta = registry.accounts[String(current.id)] || {}
|
|
116
|
+
const cursor = accountMeta.cursor || { submission_id: 0, event_id: 0 }
|
|
117
|
+
const query = new URLSearchParams({ submission_cursor: String(cursor.submission_id || 0), event_cursor: String(cursor.event_id || 0), limit: String(Math.max(1, Math.min(500, Number(limit) || 200))), include_profile: includeProfile ? '1' : '0' })
|
|
118
|
+
const payload = await request(current.baseUrl, `/api/coach/sync?${query}`, { token: current.token })
|
|
119
|
+
const account = verifyAccount(current, payload.account)
|
|
120
|
+
const next = payload.next_cursor || cursor
|
|
121
|
+
atomicJson(join(accountDir(current.id), 'SYNC.json'), { ...payload, account, synced_at: now().toISOString() })
|
|
122
|
+
registry.accounts[String(current.id)] = { ...accountMeta, account, baseUrl: current.baseUrl, cursor: next, lastSyncAt: now().toISOString() }
|
|
123
|
+
saveRegistry(registry)
|
|
124
|
+
return { ...payload, account, cursor: next, cachedPath: join(accountDir(current.id), 'SYNC.json') }
|
|
125
|
+
},
|
|
126
|
+
async replay({ contestId, limit = 50000 } = {}) {
|
|
127
|
+
const current = requireActive()
|
|
128
|
+
const payload = await request(current.baseUrl, `/api/coach/replay/${encodeURIComponent(String(contestId))}?limit=${Math.max(1, Math.min(50000, Number(limit) || 50000))}`, { token: current.token })
|
|
129
|
+
const account = verifyAccount(current, payload.account)
|
|
130
|
+
const result = { ...payload, account, cachedPath: join(accountDir(current.id), `REPLAY-${String(contestId)}.json`) }
|
|
131
|
+
atomicJson(result.cachedPath, { ...payload, account, cached_at: now().toISOString() })
|
|
132
|
+
return result
|
|
133
|
+
},
|
|
134
|
+
disconnect() {
|
|
135
|
+
const current = active()
|
|
136
|
+
if (!current) return { ok: true, disconnected: false }
|
|
137
|
+
try { rmSync(tokenPath(current.id), { force: true }) } catch { /* best effort */ }
|
|
138
|
+
const registry = loadRegistry()
|
|
139
|
+
registry.activeAccountId = ''
|
|
140
|
+
saveRegistry(registry)
|
|
141
|
+
return { ok: true, disconnected: true, account: current.account, cached: true }
|
|
142
|
+
},
|
|
143
|
+
localStatus() {
|
|
144
|
+
const current = active()
|
|
145
|
+
return current ? { connected: true, account: current.account, activeAccountId: current.id, baseUrl: current.baseUrl } : { connected: false, account: null, activeAccountId: null }
|
|
146
|
+
},
|
|
147
|
+
}
|
|
148
|
+
}
|
package/package.json
CHANGED
package/verify-shape.mjs
CHANGED
|
@@ -169,7 +169,15 @@ const cases = [
|
|
|
169
169
|
['coach_ping', {}],
|
|
170
170
|
['coach_target', { action: 'read' }],
|
|
171
171
|
['coach_target', { action: 'set', expectedRevision: 0, contest: '测试区域赛', date: '2026-10-18', result: '金牌', teamMode: 'team', weeklyHours: 20, priorities: [{ id: 'contest-reading', weight: 80, reason: '测试目标' }] }],
|
|
172
|
+
// 真正走一遍「目标 → 动作」硬闸:不属于当前能力的 progress 节点必须拒绝,
|
|
173
|
+
// 再切回无地图能力的读题卡,保留后续旧夹具的成功路径。
|
|
174
|
+
['coach_target', { action: 'set', expectedRevision: 1, contest: '测试区域赛', date: '2026-10-18', result: '金牌', teamMode: 'team', weeklyHours: 20, priorities: [{ id: 'dp-modeling', weight: 80, reason: '测试硬闸' }] }],
|
|
175
|
+
['coach_assign#target-gate', { node: 'B', deliverable: '把代码贴给我', why: '测试目标硬闸', minutes: 60, teachMinutes: 25 }],
|
|
176
|
+
['coach_target', { action: 'set', expectedRevision: 2, contest: '测试区域赛', date: '2026-10-18', result: '金牌', teamMode: 'team', weeklyHours: 20, priorities: [{ id: 'contest-reading', weight: 80, reason: '测试目标' }] }],
|
|
172
177
|
['coach_vp_import', { events: [{ eventId: 'shape-vp', contest: '测试 VP', date: today, durationMinutes: 300, problems: [{ problemId: 'A', status: 'AC', competencies: ['contest-reading'] }] }] }],
|
|
178
|
+
['coach_vp_connect', { action: 'status' }],
|
|
179
|
+
['coach_vp_sync', {}],
|
|
180
|
+
['coach_vp_replay', { contestId: 1 }],
|
|
173
181
|
['coach_postmortem', { eventId: 'shape-vp', competencies: ['contest-reading'], rootCauses: ['测试复盘'], nextActions: ['测试动作'] }],
|
|
174
182
|
['coach_focus', {}],
|
|
175
183
|
['coach_scope', { id: 'contest-reading' }],
|
|
@@ -298,6 +306,11 @@ for (const [name, args, preset] of cases) {
|
|
|
298
306
|
bad++
|
|
299
307
|
continue
|
|
300
308
|
}
|
|
309
|
+
if (name === 'coach_assign#target-gate' && out.accepted !== false) {
|
|
310
|
+
console.log(` FAIL ${name} → 目标硬闸没有拒绝无关 progress 节点`)
|
|
311
|
+
bad++
|
|
312
|
+
continue
|
|
313
|
+
}
|
|
301
314
|
const errs = []
|
|
302
315
|
validate(tool.output.schema, out, 'out', errs)
|
|
303
316
|
let renderNote = ''
|