dsh-project-based-learning 1.1.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/CONTRIBUTING.md +80 -0
  3. package/LICENSE +21 -0
  4. package/README.md +120 -0
  5. package/README.zh.md +118 -0
  6. package/cordis.patch.yml +15 -0
  7. package/docs/DESIGN-AUDIT.md +505 -0
  8. package/docs/ENGINE-REVISION-2.zh.md +487 -0
  9. package/docs/installing.zh.md +105 -0
  10. package/docs/original-workflow.zh.md +379 -0
  11. package/docs/releasing.zh.md +85 -0
  12. package/docs/review-round1-A-edu.zh.md +66 -0
  13. package/docs/review-round1-B-eng.zh.md +60 -0
  14. package/docs/review-round1-C-bounded.zh.md +55 -0
  15. package/docs/zero-knowledge-path.zh.md +60 -0
  16. package/examples/PROGRESS.demo.md +72 -0
  17. package/examples/state.demo.json +185 -0
  18. package/examples/state.selftest-invalid.json +58 -0
  19. package/lib/index.js +64 -0
  20. package/package.json +77 -0
  21. package/skills/dsh-coach/SKILL.md +108 -0
  22. package/skills/dsh-coach/assets/review-report.md +40 -0
  23. package/skills/dsh-coach/assets/stage-acceptance.md +51 -0
  24. package/skills/dsh-coach/assets/state.template.json +59 -0
  25. package/skills/dsh-coach/assets/task-card.md +29 -0
  26. package/skills/dsh-coach/references/domains/unity-csharp/archetypes.md +306 -0
  27. package/skills/dsh-coach/references/domains/unity-csharp/diagnosis-bank.md +978 -0
  28. package/skills/dsh-coach/references/domains/unity-csharp/example.md +356 -0
  29. package/skills/dsh-coach/references/domains/unity-csharp/glossary.md +110 -0
  30. package/skills/dsh-coach/references/domains/unity-csharp/manifest.yml +14 -0
  31. package/skills/dsh-coach/references/domains/unity-csharp/pitfalls.md +400 -0
  32. package/skills/dsh-coach/references/domains/unity-csharp/verification.md +308 -0
  33. package/skills/dsh-coach/references/engine/adapt.md +48 -0
  34. package/skills/dsh-coach/references/engine/diagnosis.md +76 -0
  35. package/skills/dsh-coach/references/engine/domain-contract.md +73 -0
  36. package/skills/dsh-coach/references/engine/intake.md +63 -0
  37. package/skills/dsh-coach/references/engine/permissions.md +44 -0
  38. package/skills/dsh-coach/references/engine/review-acceptance.md +67 -0
  39. package/skills/dsh-coach/references/engine/route.md +51 -0
  40. package/skills/dsh-coach/references/engine/state.md +116 -0
  41. package/skills/dsh-coach/references/engine/task-loop.md +68 -0
  42. package/skills/dsh-coach/scripts/coach-install.mjs +98 -0
  43. package/skills/dsh-coach/scripts/coach-selftest.mjs +205 -0
  44. package/skills/dsh-coach/scripts/coach-validate.mjs +817 -0
@@ -0,0 +1,817 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * coach-validate.mjs — 教学教练状态与领域包校验器
4
+ *
5
+ * 用法:
6
+ * node scripts/coach-validate.mjs [--state <state.json>] [--domain-dir <dir>]
7
+ * [--skill-dir <dir>] [--render] [--out <PROGRESS.md>] [--json] [--quiet]
8
+ *
9
+ * 默认值:
10
+ * --state .coach/state.json(相对当前工作目录)
11
+ * --skill-dir scripts/ 的上一级目录
12
+ * --domain-dir <skill-dir>/references/domains/<state.domain>
13
+ * --render 在 state 同目录写 PROGRESS.md(生成物,勿手工编辑)
14
+ *
15
+ * 退出码:0 全部通过;1 存在 error(warn 不影响退出码)。
16
+ *
17
+ * 覆盖边界(如实声明):本脚本只校验状态层与领域包结构,不能校验对话质量
18
+ * (例如是否真的只给出了 3 级提示、是否泄露完整答案)。
19
+ */
20
+
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+ import { fileURLToPath } from 'node:url';
24
+
25
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
26
+ const SKILL_DIR_DEFAULT = path.resolve(HERE, '..');
27
+
28
+ const DIMENSIONS = ['基础知识', '实际应用', '问题拆解', '调试与纠错', '结构与质量', '独立程度', '解释与迁移'];
29
+ const STATUS = ['已验证', '部分验证', '待验证'];
30
+ const SEVERITIES = ['阻塞', '重要', '建议'];
31
+ const OPEN_STATUS = ['未解决', '已解决'];
32
+ /** open[].checkStatus:R10 实测要求的依据是否已核对(缺省视为"推测") */
33
+ const CHECK_STATUS = ['已核对', '推测'];
34
+ const STAGE_STATUS = ['未开始', '进行中', '待验收', '已通过', '有条件通过', '未通过'];
35
+ const AUTH_MODES = ['read', 'write'];
36
+ const SECTIONS = ['archetypes', 'diagnosis', 'verification', 'pitfalls', 'example', 'glossary'];
37
+ const MANIFEST_KEYS = ['id', 'name', 'version', 'engine', 'locale', 'sections', 'taskMinutes', 'notes'];
38
+ const SCHEMA_VERSION = '1.0';
39
+
40
+ /** 引擎层禁止出现的学科硬令牌(分层检查) */
41
+ const DOMAIN_TOKENS = ['unity', 'c#', 'monobehaviour', 'unityengine', 'gameobject', 'prefab', 'asmdef', 'scriptableobject'];
42
+
43
+ /** 占位符式材料引用:不算可核对材料 */
44
+ const PLACEHOLDER_ARTIFACTS = new Set(['-', '—', '–', '无', '暂无', '略', 'n/a', 'na', 'none', 'tbd', '待补', '待填', '待定']);
45
+
46
+ /** 严格 ISO 8601:日期 + 时间 + 时区(Date.parse 会接受 "March 8, 2026" 之类的宽泛写法) */
47
+ const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?(Z|[+-]\d{2}:\d{2})$/;
48
+
49
+ /** 单行内查找全部整词(避免 community 命中 unity 这类假阳性;一行可命中多个令牌) */
50
+ function findDomainTokens(line) {
51
+ const norm = line.normalize('NFKC').toLowerCase();
52
+ const hits = [];
53
+ for (const token of DOMAIN_TOKENS) {
54
+ const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
55
+ const re = new RegExp(`(?<![a-z0-9])${escaped}(?![a-z0-9])`);
56
+ if (re.test(norm)) hits.push(token);
57
+ }
58
+ return hits;
59
+ }
60
+
61
+ // ── 参数 ─────────────────────────────────────────────────────────────────────
62
+
63
+ function parseArgs(argv) {
64
+ const out = { render: false, json: false, quiet: false };
65
+ for (let i = 0; i < argv.length; i += 1) {
66
+ const a = argv[i];
67
+ const next = () => {
68
+ const v = argv[i + 1];
69
+ if (v === undefined || v.startsWith('--')) throw new Error(`缺少参数值:${a}`);
70
+ i += 1;
71
+ return v;
72
+ };
73
+ if (a === '--state') out.state = next();
74
+ else if (a === '--domain-dir') out.domainDir = next();
75
+ else if (a === '--skill-dir') out.skillDir = next();
76
+ else if (a === '--out') out.out = next();
77
+ else if (a === '--render') out.render = true;
78
+ else if (a === '--json') out.json = true;
79
+ else if (a === '--quiet') out.quiet = true;
80
+ else if (a === '--help' || a === '-h') out.help = true;
81
+ else throw new Error(`未知参数:${a}`);
82
+ }
83
+ return out;
84
+ }
85
+
86
+ // ── 极简 YAML 子集解析(仅支持 manifest.yml 的受限结构)─────────────────────
87
+ // 支持:`key: value`、一层缩进映射、行内数组 [a, b]、引号字符串、# 注释。
88
+ // 不支持:锚点、多行块、嵌套数组、多层缩进。违反限制时报错而不是静默误读。
89
+
90
+ function parseSimpleYaml(text, fileLabel) {
91
+ const root = {};
92
+ const stack = [{ indent: -1, obj: root }];
93
+ const lines = text.split(/\r?\n/);
94
+
95
+ for (let i = 0; i < lines.length; i += 1) {
96
+ const raw = lines[i];
97
+ if (!raw.trim() || raw.trim().startsWith('#')) continue;
98
+ const indent = raw.match(/^ */)[0].length;
99
+ if (indent % 2 !== 0) throw new Error(`${fileLabel}:${i + 1} 缩进必须为 2 的倍数`);
100
+ const body = raw.trim();
101
+ const m = body.match(/^([^:]+):(.*)$/);
102
+ if (!m) throw new Error(`${fileLabel}:${i + 1} 无法解析:${body}`);
103
+ const key = m[1].trim();
104
+ const rest = m[2].trim();
105
+
106
+ while (stack.length > 1 && indent <= stack[stack.length - 1].indent) stack.pop();
107
+ const parent = stack[stack.length - 1].obj;
108
+ if (indent > stack[stack.length - 1].indent + 2 && stack[stack.length - 1].indent !== -1) {
109
+ // 父层必须先出现嵌套映射
110
+ }
111
+
112
+ if (rest === '') {
113
+ const child = {};
114
+ parent[key] = child;
115
+ stack.push({ indent, obj: child });
116
+ continue;
117
+ }
118
+ if (rest.startsWith('[') && rest.endsWith(']')) {
119
+ parent[key] = rest
120
+ .slice(1, -1)
121
+ .split(',')
122
+ .map((s) => s.trim())
123
+ .filter((s) => s !== '')
124
+ .map(coerce);
125
+ continue;
126
+ }
127
+ parent[key] = coerce(rest);
128
+ }
129
+ return root;
130
+ }
131
+
132
+ function coerce(v) {
133
+ const unquoted = /^"(.*)"$/.exec(v) || /^'(.*)'$/.exec(v);
134
+ if (unquoted) return unquoted[1];
135
+ if (/^-?\d+$/.test(v)) return Number(v);
136
+ if (v === 'true') return true;
137
+ if (v === 'false') return false;
138
+ return v;
139
+ }
140
+
141
+ // ── 校验框架 ─────────────────────────────────────────────────────────────────
142
+
143
+ class Report {
144
+ constructor() {
145
+ this.issues = [];
146
+ }
147
+ error(rule, where, msg, hint) {
148
+ this.issues.push({ level: 'error', rule, where, msg, hint });
149
+ }
150
+ warn(rule, where, msg, hint) {
151
+ this.issues.push({ level: 'warn', rule, where, msg, hint });
152
+ }
153
+ get errors() {
154
+ return this.issues.filter((i) => i.level === 'error');
155
+ }
156
+ get warns() {
157
+ return this.issues.filter((i) => i.level === 'warn');
158
+ }
159
+ }
160
+
161
+ /** 读取 UTF-8 文本并去掉 BOM:Windows 编辑器常写入 BOM,会让 JSON.parse 与 YAML 首键解析失败 */
162
+ function readText(p) {
163
+ return fs.readFileSync(p, 'utf8').replace(/^\uFEFF/, '');
164
+ }
165
+
166
+ const isStr = (v) => typeof v === 'string';
167
+ const isNonEmptyStr = (v) => isStr(v) && v.trim() !== '';
168
+ const isStrArray = (v) => Array.isArray(v) && v.every(isStr);
169
+ const isNonEmptyStrArray = (v) => Array.isArray(v) && v.length > 0 && v.every(isNonEmptyStr);
170
+ const isInt = (v) => Number.isInteger(v);
171
+ const isIsoTime = (v) => isStr(v) && ISO_RE.test(v) && !Number.isNaN(Date.parse(v));
172
+
173
+ function requireKeys(r, obj, where, keys) {
174
+ if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
175
+ r.error('ST-TYPE', where, '应为对象', null);
176
+ return false;
177
+ }
178
+ let ok = true;
179
+ for (const k of keys) {
180
+ if (!(k in obj)) {
181
+ r.error('ST-MISSING', `${where}.${k}`, '缺少必需字段', null);
182
+ ok = false;
183
+ }
184
+ }
185
+ return ok;
186
+ }
187
+
188
+ // ── 状态校验 ─────────────────────────────────────────────────────────────────
189
+
190
+ function checkState(r, state) {
191
+ // 注意:不因缺一个键就 return——那会让一处缺失掩盖其余全部问题。
192
+ requireKeys(r, state, '$', [
193
+ 'schemaVersion', 'engineVersion', 'domain', 'domainVersion', 'assumeGoal', 'revision', 'updatedAt',
194
+ 'goal', 'capability', 'strategy', 'route', 'current', 'evidence', 'open', 'routeChanges',
195
+ 'directAnswers', 'authorizations', 'completedTasks', 'retrievalRecap', 'nextTask',
196
+ ]);
197
+
198
+ if (state.schemaVersion !== SCHEMA_VERSION) {
199
+ r.error('ST01', '$.schemaVersion', `期望 "${SCHEMA_VERSION}",实际 ${JSON.stringify(state.schemaVersion)}`, '升级状态文件或使用匹配的引擎版本');
200
+ }
201
+ for (const k of ['engineVersion', 'domain', 'domainVersion']) {
202
+ if (!isNonEmptyStr(state[k])) r.error('ST01', `$.${k}`, '必须是非空字符串', null);
203
+ }
204
+ if (typeof state.assumeGoal !== 'boolean') r.error('ST01', '$.assumeGoal', '必须是布尔值', null);
205
+ if (!isInt(state.revision) || state.revision < 1) r.error('ST12', '$.revision', '必须是 ≥1 的整数', '每次写入 +1');
206
+ if (!isIsoTime(state.updatedAt)) {
207
+ r.error('ST12', '$.updatedAt', '必须是严格 ISO 8601(含日期、时间与时区,例如 2026-03-08T20:15:00.000Z)', null);
208
+ }
209
+ if (!isStr(state.retrievalRecap)) r.error('ST10', '$.retrievalRecap', '必须是字符串(无复述时为空串)', null);
210
+ if (!isStr(state.nextTask)) r.error('ST-TYPE', '$.nextTask', '必须是字符串', null);
211
+
212
+ checkGoal(r, state.goal);
213
+ checkCapability(r, state.capability, state.evidence);
214
+ checkEvidence(r, state.evidence);
215
+ checkRoute(r, state.route, state.current);
216
+ checkCurrent(r, state.current, state.route, state.open, state.retrievalRecap);
217
+ checkOpen(r, state.open);
218
+ checkSimpleArrays(r, state);
219
+ }
220
+
221
+ function checkGoal(r, goal) {
222
+ if (!requireKeys(r, goal, '$.goal', ['statement', 'deliverable', 'why', 'doneCriteria', 'constraints', 'nonGoals'])) return;
223
+ if (!isNonEmptyStr(goal.statement)) r.error('ST11', '$.goal.statement', '目标必须落盘且非空(验收判据的唯一来源)', null);
224
+ if (!isNonEmptyStr(goal.deliverable)) r.error('ST11', '$.goal.deliverable', '可交付成果必须非空且可观察', null);
225
+ if (!isStr(goal.why)) r.error('ST-TYPE', '$.goal.why', '必须是字符串', null);
226
+ if (!isNonEmptyStrArray(goal.doneCriteria)) r.error('ST11', '$.goal.doneCriteria', '完成判据必须是非空的非空字符串数组', null);
227
+ if (!isStrArray(goal.nonGoals)) r.error('ST-TYPE', '$.goal.nonGoals', '必须是字符串数组', null);
228
+ if (requireKeys(r, goal.constraints, '$.goal.constraints', ['time', 'tools', 'environment', 'permissions'])) {
229
+ for (const k of ['time', 'tools', 'environment', 'permissions']) {
230
+ if (!isStr(goal.constraints[k])) r.error('ST-TYPE', `$.goal.constraints.${k}`, '必须是字符串(未知时填空串)', null);
231
+ else if (goal.constraints[k].trim() === '') r.warn('ST-W1', `$.goal.constraints.${k}`, '限制未填写,审阅与验收时可能缺少边界依据', null);
232
+ }
233
+ }
234
+ }
235
+
236
+ function checkCapability(r, capability, evidence) {
237
+ if (!Array.isArray(capability)) {
238
+ r.error('ST02', '$.capability', '必须是数组', null);
239
+ return;
240
+ }
241
+ const seen = new Set();
242
+ const evIndex = new Map();
243
+ for (const e of Array.isArray(evidence) ? evidence : []) {
244
+ if (e && isNonEmptyStr(e.id) && !evIndex.has(e.id)) evIndex.set(e.id, e);
245
+ }
246
+ const strengthsOf = (ids) => ids.map((id) => (evIndex.get(id) ? evIndex.get(id).strength : undefined));
247
+ const evidenceIds = new Set(evIndex.keys());
248
+
249
+ capability.forEach((c, i) => {
250
+ const w = `$.capability[${i}]`;
251
+ if (!requireKeys(r, c, w, ['dimension', 'level', 'status', 'evidence', 'gap', 'impact'])) return;
252
+ if (!DIMENSIONS.includes(c.dimension)) r.error('ST02', `${w}.dimension`, `未知维度 ${JSON.stringify(c.dimension)}`, `允许:${DIMENSIONS.join(' / ')}`);
253
+ if (seen.has(c.dimension)) r.error('ST02', `${w}.dimension`, `维度重复:${c.dimension}`, null);
254
+ seen.add(c.dimension);
255
+ if (!isInt(c.level) || c.level < 1 || c.level > 5) r.error('ST03', `${w}.level`, '必须是 1–5 的整数', null);
256
+ if (!STATUS.includes(c.status)) r.error('ST03', `${w}.status`, `状态必须是 ${STATUS.join(' / ')}`, null);
257
+ if (!isStrArray(c.evidence)) r.error('ST-TYPE', `${w}.evidence`, '必须是字符串数组(证据 id)', null);
258
+ const ev = Array.isArray(c.evidence) ? c.evidence : [];
259
+
260
+ if (c.status === '已验证') {
261
+ if (ev.length === 0) r.error('ST03', `${w}.status`, '标记"已验证"但没有证据引用', '补 evidence,或降级为"部分验证/待验证"');
262
+ if (isInt(c.level) && c.level < 3) r.error('ST03', `${w}.level`, '"已验证"要求等级 ≥3', null);
263
+ // 只查引用 id 存在是不够的:被引用证据的强度必须能支撑"已验证",
264
+ // 否则可以靠引用一条"待验证"证据把维度刷成"已验证"。
265
+ if (ev.length > 0) {
266
+ const strengths = strengthsOf(ev);
267
+ const unknown = strengths.filter((s) => s === undefined).length;
268
+ if (unknown < ev.length && !strengths.includes('已验证')) {
269
+ r.error('ST03', `${w}.status`, `标记"已验证",但引用的证据强度为 ${strengths.filter(Boolean).join(' / ') || '(无)'},没有一条是"已验证"`, '把被引用证据的强度提升为"已验证",或把本维度降级为"部分验证"');
270
+ }
271
+ }
272
+ // F8-lite(修订 2.1):知识类结论疑为"单题即发已验证"——**只提醒,不拦截**。
273
+ // 取舍理由见 docs/ENGINE-REVISION-2.zh.md §7.3:误报的代价是每次正常教学被阻塞。
274
+ // 闭合的绕过路径(第 2 轮审核提出):
275
+ // A 改前缀("诊断问答:")→ 改为按题号正则识别,不看前缀;
276
+ // B 混入一条非问答记录 → 已收窄为"只统计 strength=已验证 的证据",故混入"待验证"证据无效;
277
+ // **残余**:混入一条 strength=已验证、artifact 不含题号的行为类证据 → untagged>0 → 完全抑制本提醒。
278
+ // 这是 warn-only 的已知边界,如实记录在 §7.3,不通过加严判据去堵(那会误伤正常表述)。
279
+ // C 伪造题号("Q1-1 加难追问(对照 Q2-1)")→ 题号归一化为 `主-次` 并取首个匹配;
280
+ // E 裸 `Q` 误报 → 正则要求 `Q<数字>-<数字>`。
281
+ // 残余边界(如实声明):把 status 降为"部分验证"仍可完全回避本提醒——这正是它**只做提醒**的原因。
282
+ if (ev.length > 0) {
283
+ const refs = ev.map((id) => evIndex.get(id)).filter(Boolean);
284
+ const verified = refs.filter((e) => e.strength === '已验证');
285
+ const topicOf = (e) => {
286
+ const m = /Q(\d+)-(\d+)/i.exec(String(e.artifact));
287
+ return m ? `${m[1]}-${m[2]}` : null;
288
+ };
289
+ const untagged = verified.filter((e) => topicOf(e) === null).length;
290
+ if (verified.length > 0 && untagged === 0) {
291
+ const topics = new Set(verified.map(topicOf));
292
+ if (topics.size < 2) {
293
+ r.warn('ST-W7', `${w}.status`, '知识类结论疑为"单题即发已验证":标"已验证"的证据全部指向同一道题(按题号去重)', '按 diagnosis.md 的证据分层:知识类需"无提示解释机制 + 迁移到新情境";单题正确只算"部分验证"');
294
+ }
295
+ }
296
+ }
297
+ }
298
+ if (c.status === '部分验证' && ev.length > 0) {
299
+ const strengths = strengthsOf(ev);
300
+ const usable = strengths.some((s) => s === '已验证' || s === '部分验证');
301
+ if (!usable) {
302
+ r.error('ST04', `${w}.status`, '标记"部分验证",但引用的证据强度全是"待验证"', '降级为"待验证",或先提升证据强度');
303
+ }
304
+ }
305
+ if (ev.length === 0) {
306
+ if (isInt(c.level) && c.level > 2) r.error('ST04', `${w}.level`, '无任何证据时等级不得超过 2', '把等级降到 ≤2,或补充证据');
307
+ if (c.status !== '待验证') r.error('ST04', `${w}.status`, '无证据时必须标"待验证"', null);
308
+ }
309
+ for (const id of ev) {
310
+ if (!evidenceIds.has(id)) r.error('ST05', `${w}.evidence`, `引用了不存在的证据 id:${id}`, null);
311
+ }
312
+ if (!isStr(c.gap)) r.error('ST-TYPE', `${w}.gap`, '必须是字符串', null);
313
+ if (!isStr(c.impact)) r.error('ST-TYPE', `${w}.impact`, '必须是字符串', null);
314
+ });
315
+
316
+ for (const d of DIMENSIONS) {
317
+ if (!seen.has(d)) r.error('ST02', '$.capability', `缺少维度:${d}`, '7 个维度必须齐全,哪怕标"待验证"');
318
+ }
319
+ }
320
+
321
+ function checkEvidence(r, evidence) {
322
+ if (!Array.isArray(evidence)) {
323
+ r.error('ST-TYPE', '$.evidence', '必须是数组', null);
324
+ return;
325
+ }
326
+ const ids = new Set();
327
+ evidence.forEach((e, i) => {
328
+ const w = `$.evidence[${i}]`;
329
+ if (!requireKeys(r, e, w, ['id', 'stage', 'claim', 'artifact', 'strength', 'note'])) return;
330
+ if (!isNonEmptyStr(e.id)) r.error('ST06', `${w}.id`, '证据 id 必须非空', null);
331
+ else if (ids.has(e.id)) r.error('ST06', `${w}.id`, `证据 id 重复:${e.id}`, null);
332
+ else ids.add(e.id);
333
+ if (!isInt(e.stage) || e.stage < 0) r.error('ST-TYPE', `${w}.stage`, '必须是 ≥0 的整数(0 表示诊断期证据)', null);
334
+ if (!isNonEmptyStr(e.claim)) r.error('ST-TYPE', `${w}.claim`, '必须说明这条证据支持什么结论', null);
335
+ if (!isNonEmptyStr(e.artifact)) r.error('ST06', `${w}.artifact`, '证据必须指向可核对材料(文件与行号/日志片段/截图位置/可复现步骤/问答记录/操作自述记录)', '能力自述不是证据;缺口自述转 R9 讲授;操作自述按"部分验证"接受,不要求重复实测');
336
+ else if (PLACEHOLDER_ARTIFACTS.has(e.artifact.trim().toLowerCase())) {
337
+ r.error('ST06', `${w}.artifact`, `"${e.artifact}" 是占位符,不是可核对材料`, '写出具体文件路径与行号、日志片段、截图位置或可复现步骤');
338
+ }
339
+ if (isStr(e.artifact) && e.artifact.length > 500) {
340
+ r.warn('ST-W5', `${w}.artifact`, `材料引用长达 ${e.artifact.length} 字符,疑似内联大段内容`, '档案只保存结论与证据摘要,大段代码/日志应指向文件而不是内联');
341
+ }
342
+ if (isStr(e.note) && e.note.length > 500) {
343
+ r.warn('ST-W5', `${w}.note`, `说明长达 ${e.note.length} 字符,疑似内联大段内容`, '同上:只保存结论与证据摘要');
344
+ }
345
+ if (!STATUS.includes(e.strength)) r.error('ST-TYPE', `${w}.strength`, `强度必须是 ${STATUS.join(' / ')}`, null);
346
+ if (!isStr(e.note)) r.error('ST-TYPE', `${w}.note`, '必须是字符串', null);
347
+ });
348
+ }
349
+
350
+ function checkRoute(r, route, current) {
351
+ if (!Array.isArray(route)) {
352
+ r.error('ST-TYPE', '$.route', '必须是数组', null);
353
+ return;
354
+ }
355
+ route.forEach((s, i) => {
356
+ const w = `$.route[${i}]`;
357
+ if (!requireKeys(r, s, w, ['n', 'name', 'deliverable', 'nonGoals', 'skills', 'prereq', 'tasks', 'userOnly', 'acceptance', 'risks', 'estimate', 'next'])) return;
358
+ if (!isInt(s.n) || s.n < 1) r.error('ST07', `${w}.n`, '阶段号必须是 ≥1 的整数', null);
359
+ if (i > 0 && isInt(s.n) && isInt(route[i - 1].n) && s.n !== route[i - 1].n + 1) {
360
+ r.error('ST07', `${w}.n`, `阶段号必须从 1 连续递增(上一阶段为 ${route[i - 1].n})`, null);
361
+ }
362
+ if (!isNonEmptyStr(s.name)) r.error('ST07', `${w}.name`, '阶段名不能为空', null);
363
+ if (!isNonEmptyStr(s.deliverable)) r.error('ST07', `${w}.deliverable`, '可交付成果不能为空', null);
364
+ if (!isNonEmptyStrArray(s.userOnly)) r.error('ST07', `${w}.userOnly`, '"用户必须亲自完成的部分"必须非空(验收时逐项核对)', null);
365
+ if (!isNonEmptyStrArray(s.acceptance)) r.error('ST07', `${w}.acceptance`, '验收标准必须非空且可判定', null);
366
+ for (const k of ['nonGoals', 'skills', 'prereq', 'tasks', 'risks']) {
367
+ if (!isStrArray(s[k])) r.error('ST-TYPE', `${w}.${k}`, '必须是字符串数组', null);
368
+ }
369
+ if (!isStr(s.estimate)) r.error('ST-TYPE', `${w}.estimate`, '必须是字符串', null);
370
+ if (!isStr(s.next)) r.error('ST-TYPE', `${w}.next`, '必须是字符串', null);
371
+ });
372
+
373
+ const ns = new Set(route.map((s) => s && s.n));
374
+ if (route.length > 0 && isInt(route[0].n) && route[0].n !== 1) {
375
+ r.error('ST07', '$.route[0].n', `阶段号必须从 1 开始(实际为 ${route[0].n})`, null);
376
+ }
377
+ if (current && isInt(current.stage)) {
378
+ if (route.length === 0) {
379
+ r.warn('ST-W4', '$.route', '尚未建立阶段路线(intake/基线阶段属正常)', '首个任务卡之前必须补齐 route');
380
+ } else if (!ns.has(current.stage)) {
381
+ r.error('ST08', '$.current.stage', `阶段 ${current.stage} 不存在于 route`, '先补 route 或修正 current.stage');
382
+ }
383
+ }
384
+ }
385
+
386
+ function checkCurrent(r, current, route, open, retrievalRecap) {
387
+ if (!requireKeys(r, current, '$.current', ['stage', 'stageStatus', 'task'])) return;
388
+ if (!isInt(current.stage) || current.stage < 1) r.error('ST08', '$.current.stage', '必须是 ≥1 的整数', null);
389
+ if (!STAGE_STATUS.includes(current.stageStatus)) r.error('ST-TYPE', '$.current.stageStatus', `必须是 ${STAGE_STATUS.join(' / ')}`, null);
390
+
391
+ const t = current.task;
392
+ if (requireKeys(r, t, '$.current.task', ['title', 'deliverable', 'criteria', 'limits', 'nonGoals', 'estimateMin', 'state'])) {
393
+ if (!isStr(t.title)) r.error('ST-TYPE', '$.current.task.title', '必须是字符串', null);
394
+ if (!isStr(t.deliverable)) r.error('ST-TYPE', '$.current.task.deliverable', '必须是字符串', null);
395
+ for (const k of ['criteria', 'limits', 'nonGoals']) {
396
+ if (!isStrArray(t[k])) r.error('ST-TYPE', `$.current.task.${k}`, '必须是字符串数组', null);
397
+ }
398
+ if (t.criteria && Array.isArray(t.criteria) && t.criteria.length === 0 && t.state && t.state !== '未开始') {
399
+ r.warn('ST-W2', '$.current.task.criteria', '任务已开始但完成标准为空', '任务卡应给出可判定的完成标准');
400
+ }
401
+ if (!isInt(t.estimateMin) || t.estimateMin < 1) r.error('ST-TYPE', '$.current.task.estimateMin', '必须是正整数的分钟估计', null);
402
+ if (!STAGE_STATUS.includes(t.state)) r.error('ST-TYPE', '$.current.task.state', `必须是 ${STAGE_STATUS.join(' / ')}`, null);
403
+ }
404
+
405
+ const hasBlocker = Array.isArray(open) && open.some((o) => o && o.severity === '阻塞' && o.status === '未解决');
406
+ if (hasBlocker && current.stageStatus === '已通过') {
407
+ r.error('ST09', '$.current.stageStatus', '存在未解决的"阻塞"项时不得判为"已通过"', '先解决阻塞项,或改为"有条件通过/未通过"');
408
+ }
409
+ if (current.stageStatus === '已通过' && isStr(retrievalRecap) && retrievalRecap.trim() === '') {
410
+ r.error('ST10', '$.retrievalRecap', '"已通过"要求先完成检索式复述并记录结论', null);
411
+ }
412
+ // route 为空时的 ST08 已在 checkRoute 中改为提醒;但任务一旦开工就必须属于某个阶段
413
+ const tk = current.task || {};
414
+ const taskStarted = isNonEmptyStr(tk.title) || (isStr(tk.state) && tk.state !== '未开始');
415
+ const routeEmpty = !Array.isArray(route) || route.length === 0;
416
+ if (routeEmpty && taskStarted) {
417
+ r.error('ST08', '$.current.task', '已开始任务但 route 为空:任务必须属于某个阶段', '先建立 route(阶段与验收标准)再出任务卡');
418
+ }
419
+ }
420
+
421
+ function checkOpen(r, open) {
422
+ if (!Array.isArray(open)) {
423
+ r.error('ST-TYPE', '$.open', '必须是数组', null);
424
+ return;
425
+ }
426
+ open.forEach((o, i) => {
427
+ const w = `$.open[${i}]`;
428
+ if (!requireKeys(r, o, w, ['id', 'issue', 'severity', 'status', 'next'])) return;
429
+ if (!isNonEmptyStr(o.id)) r.error('ST-TYPE', `${w}.id`, '待解决项 id 必须非空', null);
430
+ if (!isNonEmptyStr(o.issue)) r.error('ST-TYPE', `${w}.issue`, '问题描述必须非空', null);
431
+ if (!SEVERITIES.includes(o.severity)) r.error('ST-TYPE', `${w}.severity`, `严重程度必须是 ${SEVERITIES.join(' / ')}`, null);
432
+ if (!OPEN_STATUS.includes(o.status)) r.error('ST-TYPE', `${w}.status`, `状态必须是 ${OPEN_STATUS.join(' / ')}`, null);
433
+ // R10 留痕:basis(依据的文档章节/行号)与 checkStatus(已核对/推测)为**可选**字段,
434
+ // 兼容既有状态文件;出现时校验类型与枚举。
435
+ if ('basis' in o && !isStr(o.basis)) r.error('ST-TYPE', `${w}.basis`, '必须是字符串(依据:文档章节或文件行号)', null);
436
+ if ('checkStatus' in o) {
437
+ if (!CHECK_STATUS.includes(o.checkStatus)) r.error('ST-TYPE', `${w}.checkStatus`, `核对状态必须是 ${CHECK_STATUS.join(' / ')}`, null);
438
+ else if (o.checkStatus === '推测') r.warn('ST-W8', `${w}.checkStatus`, '依据的核对状态为"推测"', 'R10 规定:推测状态不得据此要求实测——先自行核对文档');
439
+ }
440
+ if (!isStr(o.next)) r.error('ST-TYPE', `${w}.next`, '必须是字符串', null);
441
+ });
442
+ }
443
+
444
+ function checkSimpleArrays(r, state) {
445
+ const { routeChanges, directAnswers, authorizations, strategy } = state;
446
+
447
+ // 原文 §十 的档案项之一:已完成任务(只存摘要,不存大段内容)
448
+ if (!isStrArray(state.completedTasks)) {
449
+ r.error('ST-TYPE', '$.completedTasks', '必须是字符串数组(已完成任务摘要,可为空数组)', null);
450
+ } else {
451
+ state.completedTasks.forEach((x, i) => {
452
+ if (!isNonEmptyStr(x)) r.error('ST-TYPE', `$.completedTasks[${i}]`, '每项必须是非空摘要字符串', null);
453
+ else if (x.length > 200) r.warn('ST-W5', `$.completedTasks[${i}]`, `摘要长达 ${x.length} 字符,疑似内联大段内容`, '只保存结论摘要,细节指向材料');
454
+ });
455
+ }
456
+
457
+ if (!Array.isArray(routeChanges)) r.error('ST-TYPE', '$.routeChanges', '必须是数组', null);
458
+ else routeChanges.forEach((c, i) => {
459
+ const w = `$.routeChanges[${i}]`;
460
+ if (!requireKeys(r, c, w, ['at', 'reason', 'change'])) return;
461
+ if (!isIsoTime(c.at)) r.error('ST-TYPE', `${w}.at`, '必须是严格 ISO 8601 时间', null);
462
+ if (!isNonEmptyStr(c.reason)) r.error('ST-TYPE', `${w}.reason`, '路线变更必须说明原因', null);
463
+ if (!isNonEmptyStr(c.change)) r.error('ST-TYPE', `${w}.change`, '必须说明改了什么', null);
464
+ });
465
+
466
+ if (!Array.isArray(directAnswers)) r.error('ST-TYPE', '$.directAnswers', '必须是数组', null); else directAnswers.forEach((d, i) => {
467
+ const w = `$.directAnswers[${i}]`;
468
+ if (!requireKeys(r, d, w, ['at', 'topic'])) return;
469
+ if (!isIsoTime(d.at)) r.error('ST-TYPE', `${w}.at`, '必须是严格 ISO 8601 时间', null);
470
+ if (!isNonEmptyStr(d.topic)) r.error('ST-TYPE', `${w}.topic`, '必须记录主题', null);
471
+ });
472
+
473
+ if (!Array.isArray(authorizations)) r.error('ST-TYPE', '$.authorizations', '必须是数组', null);
474
+ else authorizations.forEach((a, i) => {
475
+ const w = `$.authorizations[${i}]`;
476
+ if (!requireKeys(r, a, w, ['scope', 'mode', 'grantedAt'])) return;
477
+ if (!isNonEmptyStr(a.scope)) r.error('ST-TYPE', `${w}.scope`, '必须记录授权对象与范围', null);
478
+ if (!AUTH_MODES.includes(a.mode)) r.error('ST-TYPE', `${w}.mode`, `授权类型必须是 ${AUTH_MODES.join(' / ')}`, null);
479
+ if (!isIsoTime(a.grantedAt)) r.error('ST-TYPE', `${w}.grantedAt`, '必须是严格 ISO 8601 时间', null);
480
+ });
481
+
482
+ if (!requireKeys(r, strategy, '$.strategy', ['deferred', 'immediate', 'practice', 'assumptions'])) return;
483
+ for (const k of ['deferred', 'immediate', 'practice', 'assumptions']) {
484
+ if (!isStrArray(strategy[k])) r.error('ST-TYPE', `$.strategy.${k}`, '必须是字符串数组', null);
485
+ }
486
+ }
487
+
488
+ // ── 领域包校验 ───────────────────────────────────────────────────────────────
489
+
490
+ function checkDomain(r, domainDir, state) {
491
+ if (!fs.existsSync(domainDir)) {
492
+ r.error('DP01', domainDir, '领域包目录不存在', '按 references/engine/domain-contract.md 创建,或修正 state.domain');
493
+ return null;
494
+ }
495
+ let manifest;
496
+ let manifestPath = path.join(domainDir, 'manifest.yml');
497
+ if (!fs.existsSync(manifestPath)) {
498
+ const jsonPath = path.join(domainDir, 'manifest.json');
499
+ if (fs.existsSync(jsonPath)) {
500
+ manifestPath = jsonPath;
501
+ try {
502
+ manifest = JSON.parse(readText(jsonPath));
503
+ } catch (e) {
504
+ r.error('DP02', manifestPath, `JSON 解析失败:${e.message}`, null);
505
+ return null;
506
+ }
507
+ } else {
508
+ r.error('DP01', manifestPath, '缺少 manifest.yml', null);
509
+ return null;
510
+ }
511
+ } else {
512
+ try {
513
+ manifest = parseSimpleYaml(readText(manifestPath), 'manifest.yml');
514
+ } catch (e) {
515
+ r.error('DP02', manifestPath, e.message, '本脚本仅支持受限 YAML 子集:键值、一层映射、行内数组');
516
+ return null;
517
+ }
518
+ }
519
+
520
+ for (const k of MANIFEST_KEYS) {
521
+ if (!(k in manifest)) r.error('DP03', `manifest.${k}`, '缺少必需键', `必需键:${MANIFEST_KEYS.join(', ')}`);
522
+ }
523
+ for (const k of Object.keys(manifest)) {
524
+ if (!MANIFEST_KEYS.includes(k)) r.error('DP03', `manifest.${k}`, '存在契约外的键', `契约固定 ${MANIFEST_KEYS.join(', ')} 八个键,不增删`);
525
+ }
526
+
527
+ if (state && isNonEmptyStr(state.domain) && manifest.id !== state.domain) {
528
+ r.error('DP04', 'manifest.id', `领域包 id (${manifest.id}) 与 state.domain (${state.domain}) 不一致`, null);
529
+ }
530
+ for (const k of ['id', 'name', 'version', 'engine', 'locale']) {
531
+ if (k in manifest && !isNonEmptyStr(manifest[k])) r.error('DP03', `manifest.${k}`, '必须是非空字符串', null);
532
+ }
533
+ if (isNonEmptyStr(manifest.engine)) {
534
+ const m = /^>=\s*(\d+)\.(\d+)\.(\d+)$/.exec(manifest.engine.trim());
535
+ if (!m) r.warn('DP-W1', 'manifest.engine', `无法解析兼容范围:${manifest.engine}`, '建议使用 ">=1.0.0" 形式');
536
+ else if (state && isNonEmptyStr(state.engineVersion)) {
537
+ const e = state.engineVersion.split('.').map(Number);
538
+ const need = [Number(m[1]), Number(m[2]), Number(m[3])];
539
+ const lower = e[0] < need[0] || (e[0] === need[0] && (e[1] < need[1] || (e[1] === need[1] && e[2] < need[2])));
540
+ if (lower) r.error('DP05', 'manifest.engine', `领域包要求引擎 ${manifest.engine},当前状态记录为 ${state.engineVersion}`, '升级引擎或改用兼容的领域包');
541
+ }
542
+ }
543
+ if ('taskMinutes' in manifest) {
544
+ const tm = manifest.taskMinutes;
545
+ if (!Array.isArray(tm) || tm.length !== 2 || !tm.every((v) => Number.isInteger(v) && v > 0) || tm[0] >= tm[1]) {
546
+ r.error('DP03', 'manifest.taskMinutes', '必须是 [下限, 上限] 且下限 < 上限', null);
547
+ }
548
+ }
549
+ if ("notes" in manifest && !isStr(manifest.notes)) r.error('DP03', 'manifest.notes', '必须是字符串', null);
550
+
551
+ const sections = manifest.sections;
552
+ if (!sections || typeof sections !== 'object' || Array.isArray(sections)) {
553
+ r.error('DP03', 'manifest.sections', '必须是映射', null);
554
+ return manifest;
555
+ }
556
+ for (const key of SECTIONS) {
557
+ const rel = sections[key];
558
+ if (!isNonEmptyStr(rel)) {
559
+ r.error('DP06', `manifest.sections.${key}`, '缺少小节声明', `必须声明 ${SECTIONS.join(' / ')}`);
560
+ continue;
561
+ }
562
+ const abs = path.join(domainDir, rel);
563
+ if (!fs.existsSync(abs)) {
564
+ r.error('DP06', abs, `小节文件不存在(sections.${key})`, null);
565
+ continue;
566
+ }
567
+ const size = fs.statSync(abs).size;
568
+ if (size < 200) r.warn('DP-W2', abs, `小节文件过小(${size} 字节),可能未实际编写`, null);
569
+ }
570
+ for (const key of Object.keys(sections)) {
571
+ if (!SECTIONS.includes(key)) r.warn('DP-W3', `manifest.sections.${key}`, '契约外的小节键', '契约小节:' + SECTIONS.join(' / '));
572
+ }
573
+ return manifest;
574
+ }
575
+
576
+ // ── 分层检查:引擎不得含学科专有词条 ─────────────────────────────────────────
577
+
578
+ function checkLayering(r, skillDir) {
579
+ const targets = [];
580
+ const skillMd = path.join(skillDir, 'SKILL.md');
581
+ if (fs.existsSync(skillMd)) targets.push(skillMd);
582
+ const engineDir = path.join(skillDir, 'references', 'engine');
583
+ if (fs.existsSync(engineDir)) {
584
+ for (const f of fs.readdirSync(engineDir)) {
585
+ if (f.endsWith('.md')) targets.push(path.join(engineDir, f));
586
+ }
587
+ }
588
+ // 模板也要扫描:它们会作为提示词直接发给用户看
589
+ const assetsDir = path.join(skillDir, 'assets');
590
+ if (fs.existsSync(assetsDir)) {
591
+ for (const f of fs.readdirSync(assetsDir)) {
592
+ if (f.endsWith('.md')) targets.push(path.join(assetsDir, f));
593
+ }
594
+ }
595
+ // 注:scripts/ 不参与扫描——校验器自身包含黑名单词表,扫它必然自命中。
596
+ if (targets.length === 0) {
597
+ r.warn('LY-W1', skillDir, '未找到 SKILL.md、references/engine 或 assets,跳过分层检查', null);
598
+ return targets.length;
599
+ }
600
+ for (const file of targets) {
601
+ const lines = readText(file).split(/\r?\n/);
602
+ lines.forEach((line, i) => {
603
+ for (const token of findDomainTokens(line)) {
604
+ r.error('LY01', `${path.relative(skillDir, file)}:${i + 1}`, `引擎层出现学科硬令牌 "${token}"`, '学科内容应放在 references/domains/<id>/ 下');
605
+ }
606
+ });
607
+ }
608
+ return targets.length;
609
+ }
610
+ // ── PROGRESS.md 生成 ─────────────────────────────────────────────────────────
611
+
612
+ function renderProgress(state, statePathLabel) {
613
+ const L = [];
614
+ const table = (rows) => rows.join('\n');
615
+ L.push('# 学习进度');
616
+ L.push('');
617
+ L.push('> 本文件由 ' + '`' + (statePathLabel || '.coach/state.json') + '`' + ' 自动生成,**请勿手工编辑**;冲突时以该状态文件为准。');
618
+ L.push(`> revision ${state.revision} | 更新于 ${state.updatedAt} | 领域包 ${state.domain}@${state.domainVersion}`);
619
+ L.push('');
620
+ L.push('## 目标');
621
+ L.push(`- 目标:${state.goal.statement}`);
622
+ L.push(`- 可交付成果:${state.goal.deliverable}`);
623
+ L.push(`- 为什么:${state.goal.why || '—'}`);
624
+ L.push(`- 完成判据:${state.goal.doneCriteria.join(';')}`);
625
+ L.push(`- 当前非目标:${state.goal.nonGoals.length ? state.goal.nonGoals.join(';') : '—'}`);
626
+ L.push(`- 限制:时间 ${state.goal.constraints.time || '—'}|工具 ${state.goal.constraints.tools || '—'}|环境 ${state.goal.constraints.environment || '—'}|权限 ${state.goal.constraints.permissions || '—'}`);
627
+ L.push('');
628
+ L.push('## 能力画像');
629
+ L.push('| 维度 | 等级 | 状态 | 证据 | 主要缺口 | 对当前目标的影响 |');
630
+ L.push('|---|---:|---|---|---|---|');
631
+ for (const c of state.capability) {
632
+ L.push(`| ${c.dimension} | ${c.level} | ${c.status} | ${c.evidence.length ? c.evidence.join(', ') : '—'} | ${c.gap || '—'} | ${c.impact || '—'} |`);
633
+ }
634
+ L.push('');
635
+ L.push('## 当前阶段与任务');
636
+ const stage = state.route.find((s) => s.n === state.current.stage);
637
+ L.push(`- 阶段 ${state.current.stage}:${stage ? stage.name : '(未在 route 中)'} | 状态:${state.current.stageStatus}`);
638
+ if (stage) {
639
+ L.push(`- 可交付成果:${stage.deliverable}`);
640
+ L.push(`- 用户必须亲自完成:${stage.userOnly.join(';')}`);
641
+ L.push(`- 验收标准:${stage.acceptance.join(';')}`);
642
+ }
643
+ const t = state.current.task;
644
+ L.push(`- 本次任务:${t.title || '—'}(${t.state},约 ${t.estimateMin} 分钟)`);
645
+ if (t.criteria.length) L.push(`- 完成标准:${t.criteria.join(';')}`);
646
+ L.push(`- 检索式复述:${state.retrievalRecap || '尚未进行'}`);
647
+ L.push('');
648
+ L.push('## 路线');
649
+ L.push('| 阶段 | 名称 | 可交付成果 | 预计投入 | 通过后进入 |');
650
+ L.push('|---|---|---|---|---|');
651
+ for (const s of state.route) {
652
+ L.push(`| ${s.n} | ${s.name} | ${s.deliverable} | ${s.estimate || '—'} | ${s.next || '—'} |`);
653
+ }
654
+ L.push('');
655
+ L.push('## 证据');
656
+ if (state.evidence.length === 0) L.push('(暂无)');
657
+ else {
658
+ L.push('| id | 阶段 | 支持结论 | 材料 | 强度 |');
659
+ L.push('|---|---:|---|---|---|');
660
+ for (const e of state.evidence) {
661
+ const stageLabel = e.stage === 0 ? '诊断期' : String(e.stage);
662
+ L.push(`| ${e.id} | ${stageLabel} | ${e.claim} | ${e.artifact} | ${e.strength} |`);
663
+ }
664
+ }
665
+ L.push('');
666
+ L.push('## 已完成任务');
667
+ L.push(state.completedTasks.length ? state.completedTasks.map((x) => `- ${x}`).join('\n') : '(暂无)');
668
+ L.push('');
669
+ L.push('## 待解决项');
670
+ const open = state.open.filter((o) => o.status === '未解决');
671
+ if (open.length === 0) L.push('(无)');
672
+ else for (const o of open) {
673
+ const trail = [o.basis ? `依据:${o.basis}` : null, o.checkStatus ? `核对状态:${o.checkStatus}` : null]
674
+ .filter(Boolean).join('|');
675
+ L.push(`- [${o.severity}] ${o.id}:${o.issue} → ${o.next}${trail ? `(${trail})` : ''}`);
676
+ }
677
+ L.push('');
678
+ L.push('## 教学策略');
679
+ L.push(`- 暂缓:${state.strategy.deferred.join(';') || '—'}`);
680
+ L.push(`- 即时补齐:${state.strategy.immediate.join(';') || '—'}`);
681
+ L.push(`- 练习方式:${state.strategy.practice.join(';') || '—'}`);
682
+ L.push(`- 待验证假设:${state.strategy.assumptions.join(';') || '—'}`);
683
+ L.push('');
684
+ if (state.routeChanges.length) {
685
+ L.push('## 路线变更');
686
+ for (const c of state.routeChanges) L.push(`- ${c.at}:${c.change}(原因:${c.reason})`);
687
+ L.push('');
688
+ }
689
+ if (state.directAnswers.length) {
690
+ L.push('## 直接答案记录(不计入能力证据)');
691
+ for (const d of state.directAnswers) L.push(`- ${d.at}:${d.topic}`);
692
+ L.push('');
693
+ }
694
+ if (state.authorizations.length) {
695
+ L.push('## 权限授权记录');
696
+ for (const a of state.authorizations) L.push(`- ${a.grantedAt}|${a.mode}|${a.scope}`);
697
+ L.push('');
698
+ }
699
+ L.push('## 下一步');
700
+ L.push(state.nextTask || '(未指定)');
701
+ L.push('');
702
+ return table(L);
703
+ }
704
+
705
+ // ── 主流程 ───────────────────────────────────────────────────────────────────
706
+
707
+ function main() {
708
+ let args;
709
+ try {
710
+ args = parseArgs(process.argv.slice(2));
711
+ } catch (e) {
712
+ console.error(`参数错误:${e.message}`);
713
+ process.exit(2);
714
+ }
715
+ if (args.help) {
716
+ console.log(
717
+ fs.readFileSync(fileURLToPath(import.meta.url), 'utf8')
718
+ .split('*/')[0]
719
+ .replace(/^#!.*\n/, '')
720
+ .replace(/^\/\*\*?/, '')
721
+ .trim(),
722
+ );
723
+ process.exit(0);
724
+ }
725
+
726
+ const skillDir = path.resolve(args.skillDir || SKILL_DIR_DEFAULT);
727
+ const statePath = path.resolve(args.state || path.join('.coach', 'state.json'));
728
+ const r = new Report();
729
+ let state = null;
730
+
731
+ if (!fs.existsSync(statePath)) {
732
+ r.error('ST00', statePath, '状态文件不存在', '用 assets/state.template.json 建立骨架后填写');
733
+ } else {
734
+ try {
735
+ const parsed = JSON.parse(readText(statePath));
736
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
737
+ const kind = parsed === null ? 'null' : Array.isArray(parsed) ? '数组' : typeof parsed;
738
+ r.error('ST00', statePath, `状态文件必须是 JSON 对象,实际为 ${kind}`, '用 assets/state.template.json 重建骨架');
739
+ } else {
740
+ state = parsed;
741
+ }
742
+ } catch (e) {
743
+ r.error('ST00', statePath, `JSON 解析失败:${e.message}`, '状态文件必须是严格 JSON(无注释、无尾逗号)');
744
+ }
745
+ if (state) checkState(r, state);
746
+ }
747
+
748
+ const domainDir = args.domainDir
749
+ ? path.resolve(args.domainDir)
750
+ : state && isNonEmptyStr(state.domain)
751
+ ? path.join(skillDir, 'references', 'domains', state.domain)
752
+ : null;
753
+ let manifest = null;
754
+ if (domainDir) manifest = checkDomain(r, domainDir, state);
755
+ else r.warn('DP-W0', skillDir, '无法确定领域包目录(state.domain 缺失且未指定 --domain-dir)', null);
756
+
757
+ // 任务时长上限:领域包给了 [下限, 上限],超出上限应拆分(原文 §七.1 + 审核裁定 W16)
758
+ if (manifest && Array.isArray(manifest.taskMinutes) && state && state.current && state.current.task) {
759
+ const [lo, hi] = manifest.taskMinutes;
760
+ const est = state.current.task.estimateMin;
761
+ if (Number.isInteger(lo) && Number.isInteger(hi) && isInt(est) && est > hi) {
762
+ r.warn('ST-W6', '$.current.task.estimateMin', `本次任务估计 ${est} 分钟,超过领域包上限 ${hi} 分钟`, '超限任务必须拆成多个可独立验收的子任务,或由用户"调整节奏"显式覆盖');
763
+ }
764
+ }
765
+
766
+ const scanned = checkLayering(r, skillDir);
767
+
768
+ if (args.render && state && r.errors.length === 0) {
769
+ const outPath = args.out ? path.resolve(args.out) : path.join(path.dirname(statePath), 'PROGRESS.md');
770
+ try {
771
+ if (fs.existsSync(outPath) && fs.statSync(outPath).isDirectory()) {
772
+ r.error('ST-W3', outPath, '--out 指向一个已存在的目录,不是文件路径', '改成具体的 .md 文件路径');
773
+ } else {
774
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
775
+ fs.writeFileSync(outPath, renderProgress(state, path.relative(process.cwd(), statePath) || statePath), 'utf8');
776
+ if (!args.quiet && !args.json) console.log(`已生成视图:${outPath}`);
777
+ }
778
+ } catch (e) {
779
+ r.error('ST-W3', outPath, `写入视图失败:${e.message}`, null);
780
+ }
781
+ } else if (args.render && state && r.errors.length > 0) {
782
+ r.warn('ST-W3', '--render', '存在 error,未生成 PROGRESS.md', '先修完 error 再渲染');
783
+ }
784
+
785
+ if (args.json) {
786
+ console.log(JSON.stringify({ ok: r.errors.length === 0, errors: r.errors, warnings: r.warns, scannedEngineFiles: scanned }, null, 2));
787
+ } else if (!args.quiet) {
788
+ const name = r.errors.length === 0 ? 'PASS' : 'FAIL';
789
+ console.log(`教练状态校验:${name}`);
790
+ console.log(`状态:${statePath}`);
791
+ if (domainDir) console.log(`领域包:${domainDir}`);
792
+ console.log(`引擎分层检查:扫描 ${scanned} 个文件`);
793
+ if (r.errors.length) {
794
+ console.log(`\n错误 ${r.errors.length} 项:`);
795
+ for (const i of r.errors) console.log(` [${i.rule}] ${i.where}\n ${i.msg}${i.hint ? `\n → ${i.hint}` : ''}`);
796
+ }
797
+ if (r.warns.length) {
798
+ console.log(`\n提醒 ${r.warns.length} 项:`);
799
+ for (const i of r.warns) console.log(` [${i.rule}] ${i.where} — ${i.msg}${i.hint ? ` → ${i.hint}` : ''}`);
800
+ }
801
+ if (r.errors.length === 0) console.log('\n未发现结构问题。注意:本校验不覆盖对话质量与教学效果。');
802
+ }
803
+
804
+ process.exit(r.errors.length === 0 ? 0 : 1);
805
+ }
806
+
807
+ const isMainModule = (() => {
808
+ try {
809
+ return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
810
+ } catch {
811
+ return false;
812
+ }
813
+ })();
814
+
815
+ if (isMainModule) main();
816
+
817
+ export { Report, checkState, checkDomain, checkLayering, renderProgress, parseSimpleYaml, readText };