scrum-skills 0.1.2 → 0.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.
Files changed (55) hide show
  1. package/.agents/skills/scrum-planning/SKILL.md +10 -4
  2. package/.agents/skills/scrum-planning/references/schema.md +141 -60
  3. package/.agents/skills/scrum-report/SKILL.md +8 -4
  4. package/.agents/skills/scrum-sprint/SKILL.md +8 -3
  5. package/.agents/skills/scrum-tdd/SKILL.md +3 -3
  6. package/README.md +132 -0
  7. package/assets/board/200.html +1 -0
  8. package/assets/board/404.html +1 -0
  9. package/assets/board/_nuxt/BNG0e--z.js +6 -0
  10. package/assets/board/_nuxt/BWaQPvUU.js +8 -0
  11. package/assets/board/_nuxt/BcDeF-Nj.js +1 -0
  12. package/assets/board/_nuxt/BeTHElHy.js +1 -0
  13. package/assets/board/_nuxt/BrhpXuqy.js +1 -0
  14. package/assets/board/_nuxt/Brv_Bdja.js +3 -0
  15. package/assets/board/_nuxt/C218H_dA.js +1 -0
  16. package/assets/board/_nuxt/C37hh6-M.js +1 -0
  17. package/assets/board/_nuxt/C6_z9Ljo.js +1 -0
  18. package/assets/board/_nuxt/CNsYxQaQ.js +1 -0
  19. package/assets/board/_nuxt/Cg4fvkkC.js +4 -0
  20. package/assets/board/_nuxt/Ci-XR_iv.js +1 -0
  21. package/assets/board/_nuxt/Cxemkb8I.js +3 -0
  22. package/assets/board/_nuxt/D1zk137d.js +1 -0
  23. package/assets/board/_nuxt/D8i1JTd1.js +1 -0
  24. package/assets/board/_nuxt/DK3Fl9T5.js +1 -0
  25. package/assets/board/_nuxt/DVUil5cj.js +1 -0
  26. package/assets/board/_nuxt/DW7rSQ02.js +1 -0
  27. package/assets/board/_nuxt/DbghXjRj.js +1 -0
  28. package/assets/board/_nuxt/DcGinDT7.js +6 -0
  29. package/assets/board/_nuxt/DhgUnnc1.js +16 -0
  30. package/assets/board/_nuxt/DqkHw_T9.js +1 -0
  31. package/assets/board/_nuxt/DuWpPgcw.js +3 -0
  32. package/assets/board/_nuxt/OHMTu69O.js +1 -0
  33. package/assets/board/_nuxt/W_UkaXSY.js +1 -0
  34. package/assets/board/_nuxt/_id_.IxcmgCc3.css +1 -0
  35. package/assets/board/_nuxt/builds/latest.json +1 -0
  36. package/assets/board/_nuxt/builds/meta/cf3421e0-2439-4091-aeea-10c06027154f.json +1 -0
  37. package/assets/board/_nuxt/burndown.DSciF97B.css +1 -0
  38. package/assets/board/_nuxt/entry.DEq3ipbJ.css +2 -0
  39. package/assets/board/_nuxt/epics.jbQSHA_o.css +1 -0
  40. package/assets/board/_nuxt/error-404.BFmXOnCT.css +1 -0
  41. package/assets/board/_nuxt/error-500.BZ-DdjQ6.css +1 -0
  42. package/assets/board/_nuxt/gantt.BZpeqgtE.css +1 -0
  43. package/assets/board/_nuxt/milestones.DqyGYjt4.css +1 -0
  44. package/assets/board/_nuxt/pages.D1M7qr1l.css +1 -0
  45. package/assets/board/burndown/index.html +1 -0
  46. package/assets/board/epics/index.html +1 -0
  47. package/assets/board/gantt/index.html +1 -0
  48. package/assets/board/index.html +1 -0
  49. package/assets/board/milestones/index.html +1 -0
  50. package/bin/scrum-skills.mjs +17 -5
  51. package/docs/board.png +0 -0
  52. package/package.json +24 -3
  53. package/templates/AGENTS.md +25 -11
  54. package/tools/serve.mjs +88 -143
  55. package/tools/validate.mjs +319 -109
@@ -1,153 +1,363 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * 敏捷数据校验脚本(零依赖)
3
+ * 敏捷数据校验脚本(零依赖,schema v3:三文件结构)
4
4
  * 用法: node tools/validate.mjs [目标目录] 默认当前工作目录
5
- * 校验 <目标>/agile/ 下所有 JSON 的引用完整性、状态机、TDD 纪律。
5
+ * 校验 <目标>/agile/ 下 backlog.json / sprints.json / journal.json:
6
+ * 引用完整性(双向)、状态机、TDD 纪律、里程碑达成口径、
7
+ * 需求属性(type/priority/value/缺陷详情/验收覆盖)、依赖无环、阻塞与范围变更。
6
8
  * 退出码: 0 通过, 1 有错误。
7
9
  */
8
- import { readdirSync, readFileSync, existsSync } from 'node:fs';
9
- import { join, basename, resolve } from 'node:path';
10
+ import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
11
+ import { join, resolve } from 'node:path';
10
12
 
11
13
  const TARGET = resolve(process.argv[2] || process.cwd());
12
14
  const AGILE = join(TARGET, 'agile');
13
15
  const errors = [];
14
16
  const warns = [];
17
+ const err = m => errors.push(m);
18
+ const warn = m => warns.push(m);
15
19
 
16
- function readJson(dir) {
17
- const out = [];
18
- if (!existsSync(dir)) return out;
19
- for (const f of readdirSync(dir)) {
20
- if (!f.endsWith('.json')) continue;
21
- const p = join(dir, f);
22
- try {
23
- out.push({ file: p, data: JSON.parse(readFileSync(p, 'utf8')) });
24
- } catch (e) {
25
- errors.push(`${p}: JSON 解析失败 - ${e.message}`);
26
- }
20
+ const FILES = { backlog: 'backlog.json', sprints: 'sprints.json', journal: 'journal.json' };
21
+ const data = {};
22
+ for (const [key, name] of Object.entries(FILES)) {
23
+ const p = join(AGILE, name);
24
+ if (!existsSync(p)) {
25
+ err(`agile/${name} 缺失(schema v2 只认三文件结构)`);
26
+ continue;
27
27
  }
28
- return out;
29
- }
30
-
31
- const epics = readJson(join(AGILE, 'backlog/epics'));
32
- const stories = readJson(join(AGILE, 'backlog/stories'));
33
- const sprints = readJson(join(AGILE, 'sprints'));
34
- const journals = readJson(join(AGILE, 'journal'));
35
-
36
- const epicIds = new Set(epics.map(e => e.data.id));
37
- const storyIds = new Set(stories.map(s => s.data.id));
38
- const sprintIds = new Set(sprints.map(s => s.data.id));
39
- const allIds = new Set();
40
- const journalTaskIds = new Set();
41
-
42
- // 文件名与内部 id 一致(journal 按 taskId 对应 task,单独去重)
43
- for (const [items, key] of [[epics, 'id'], [stories, 'id'], [sprints, 'id'], [journals, 'taskId']]) {
44
- for (const it of items) {
45
- const id = it.data[key];
46
- if (!id) { errors.push(`${it.file}: 缺少 ${key}`); continue; }
47
- const stem = basename(it.file, '.json');
48
- if (stem !== id) errors.push(`${it.file}: 文件名 ${stem} 与内部 ${key} ${id} 不一致`);
49
- const bucket = key === 'taskId' ? journalTaskIds : allIds;
50
- if (bucket.has(id)) errors.push(`${it.file}: ${key} ${id} 重复`);
51
- bucket.add(id);
28
+ try {
29
+ data[key] = JSON.parse(readFileSync(p, 'utf8'));
30
+ } catch (e) {
31
+ err(`agile/${name}: JSON 解析失败 - ${e.message}`);
32
+ continue;
52
33
  }
34
+ if (data[key].schemaVersion !== 3) err(`agile/${name}: schemaVersion 应为 3,实际 ${data[key].schemaVersion}`);
53
35
  }
54
- for (const j of journals) {
55
- const sid = j.data.storyId;
56
- if (sid && !storyIds.has(sid)) errors.push(`${j.file}: storyId ${sid} 不存在`);
36
+
37
+ // v2 不兼容散文件结构:残留就报错,提醒合并
38
+ for (const rel of ['backlog/epics', 'backlog/stories', 'sprints', 'journal']) {
39
+ const p = join(AGILE, rel);
40
+ if (existsSync(p) && statSync(p).isDirectory() && readdirSync(p).some(f => f.endsWith('.json')))
41
+ err(`agile/${rel}/ 仍有散文件 JSON:v2 只认三文件结构,请合并进 backlog.json / sprints.json / journal.json`);
57
42
  }
58
43
 
44
+ const backlog = data.backlog ?? {};
45
+ const sprintsFile = data.sprints ?? {};
46
+ const journalFile = data.journal ?? {};
47
+ const epics = Array.isArray(backlog.epics) ? backlog.epics : [];
48
+ const stories = Array.isArray(backlog.stories) ? backlog.stories : [];
49
+ const sprints = Array.isArray(sprintsFile.sprints) ? sprintsFile.sprints : [];
50
+ const milestones = Array.isArray(sprintsFile.milestones) ? sprintsFile.milestones : [];
51
+ const journals = Array.isArray(journalFile.journals) ? journalFile.journals : [];
52
+
59
53
  const STORY_FLOW = ['draft', 'ready', 'in-progress', 'review', 'done'];
60
54
  const TASK_FLOW = ['todo', 'red', 'green', 'refactored', 'done'];
55
+ const EPIC_FLOW = ['draft', 'active', 'done', 'cancelled'];
56
+ const MILESTONE_FLOW = ['planned', 'at-risk', 'reached', 'missed'];
57
+ const STORY_KIND = ['feature', 'bug', 'debt', 'spike'];
58
+ const PRIORITY = ['P0', 'P1', 'P2', 'P3'];
59
+ const SEVERITY = ['blocker', 'major', 'minor', 'trivial'];
60
+ const SCOPE_KIND = ['added-mid-sprint', 'split', 'expanded'];
61
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
62
+ // 用本地日期判定「今天」,避免 UTC 偏移导致活跃迭代判定差一天
63
+ const now = new Date();
64
+ const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
61
65
 
62
- // Story 校验
66
+ // ---- 全局唯一 id ----
67
+ const ids = new Set();
68
+ function claim(id, where) {
69
+ if (!id) { err(`${where}: 缺少 id`); return; }
70
+ if (ids.has(id)) err(`${where}: id ${id} 重复`);
71
+ else ids.add(id);
72
+ }
73
+ for (const e of epics) claim(e.id, 'epic');
63
74
  for (const s of stories) {
64
- const d = s.file && s.data;
65
- const { id, epicId, sprintId, status, acceptance, tasks } = d;
66
- if (epicId && !epicIds.has(epicId)) errors.push(`${s.file}: epicId ${epicId} 不存在`);
67
- if (sprintId && !sprintIds.has(sprintId)) errors.push(`${s.file}: sprintId ${sprintId} 不存在`);
68
- if (!STORY_FLOW.includes(status) && status !== 'cancelled') errors.push(`${s.file}: 非法 story 状态 ${status}`);
75
+ claim(s.id, 'story');
76
+ for (const t of s.tasks ?? []) {
77
+ claim(t.id, `story ${s.id} 的 task`);
78
+ if (t.id && !t.id.startsWith(`${s.id}-T`)) err(`task ${t.id}: 命名应为 ${s.id}-T<n>`);
79
+ }
80
+ }
81
+ for (const sp of sprints) claim(sp.id, 'sprint');
82
+ for (const m of milestones) claim(m.id, 'milestone');
83
+
84
+ const epicById = new Map(epics.map(e => [e.id, e]));
85
+ const storyById = new Map(stories.map(s => [s.id, s]));
86
+ const sprintById = new Map(sprints.map(s => [s.id, s]));
87
+ const taskById = new Map();
88
+ for (const s of stories) for (const t of s.tasks ?? []) taskById.set(t.id, { task: t, story: s });
89
+
90
+ // ---- 依赖图必须是有向无环 ----
91
+ {
92
+ const state = new Map(); // 1=访问中 2=已完成
93
+ const visit = (id, path) => {
94
+ const st = state.get(id) ?? 0;
95
+ if (st === 2) return;
96
+ if (st === 1) { err(`story 依赖成环: ${[...path, id].join(' → ')}`); return; }
97
+ state.set(id, 1);
98
+ for (const dep of storyById.get(id)?.dependsOn ?? []) if (storyById.has(dep)) visit(dep, [...path, id]);
99
+ state.set(id, 2);
100
+ };
101
+ for (const s of stories) visit(s.id, []);
102
+ }
103
+
104
+ // ---- Epic ----
105
+ for (const e of epics) {
106
+ if (!EPIC_FLOW.includes(e.status)) err(`epic ${e.id}: 非法状态 ${e.status}`);
107
+ if (!e.title) err(`epic ${e.id}: 缺少 title`);
108
+ if (!e.goal) err(`epic ${e.id}: 缺少 goal`);
109
+ if (!Array.isArray(e.keyResults) || e.keyResults.length === 0) warn(`epic ${e.id}: 没有 keyResults(史诗要有量化目标,否则完成与否全靠感觉)`);
110
+ for (const kr of e.keyResults ?? []) {
111
+ if (!kr.id) err(`epic ${e.id}: keyResult 缺少 id`);
112
+ if (!kr.text) err(`epic ${e.id}: keyResult ${kr.id ?? '?'} 缺少 text`);
113
+ if (!Number.isFinite(kr.target)) err(`epic ${e.id}: keyResult ${kr.id ?? '?'} 的 target 必须是数字`);
114
+ if (!Number.isFinite(kr.current)) warn(`epic ${e.id}: keyResult ${kr.id ?? '?'} 没有 current,算不出进度`);
115
+ }
116
+ for (const sid of e.stories ?? []) {
117
+ const st = storyById.get(sid);
118
+ if (!st) { err(`epic ${e.id}: 引用 story ${sid} 不存在`); continue; }
119
+ if (st.epicId !== e.id) err(`epic ${e.id}: story ${sid} 的 epicId 是 ${st.epicId},双向引用不一致`);
120
+ }
121
+ if (e.status === 'done') {
122
+ for (const sid of e.stories ?? []) {
123
+ const st = storyById.get(sid);
124
+ if (st && !['done', 'cancelled'].includes(st.status))
125
+ err(`epic ${e.id} 已 done 但 story ${sid} 状态为 ${st.status}`);
126
+ }
127
+ }
128
+ }
129
+
130
+ // ---- Story ----
131
+ for (const s of stories) {
132
+ const { id, epicId, sprintId, status, acceptance, tasks, points } = s;
133
+ if (!s.title) err(`story ${id}: 缺少 title`);
134
+ if (!epicId) warn(`story ${id}: 未挂 epic`);
135
+ else if (!epicById.has(epicId)) err(`story ${id}: epicId ${epicId} 不存在`);
136
+ else if (!(epicById.get(epicId).stories ?? []).includes(id))
137
+ err(`story ${id}: 挂在 ${epicId} 上,但该 epic 的 stories 里没有它(双向引用不一致)`);
138
+
139
+ if (!STORY_FLOW.includes(status) && status !== 'cancelled') err(`story ${id}: 非法状态 ${status}`);
140
+ if (!Number.isFinite(points)) err(`story ${id}: points 必须是数字`);
141
+ else if (![1, 2, 3, 5, 8, 13].includes(points)) warn(`story ${id}: points ${points} 不在斐波那契序列`);
142
+
143
+ // 需求属性:类型 / 优先级 / 价值
144
+ if (!STORY_KIND.includes(s.type)) err(`story ${id}: type 必须是 ${STORY_KIND.join('/')},实际 ${s.type}`);
145
+ if (!PRIORITY.includes(s.priority)) err(`story ${id}: priority 必须是 ${PRIORITY.join('/')},实际 ${s.priority}`);
146
+ if (!Number.isFinite(s.value) || s.value <= 0) err(`story ${id}: value 必须是正数(用于算 WSJF)`);
147
+ if (s.type === 'bug') {
148
+ if (!s.bug) err(`story ${id}: type 为 bug 但缺少 bug 详情`);
149
+ else {
150
+ if (!SEVERITY.includes(s.bug.severity)) err(`story ${id}: bug.severity 必须是 ${SEVERITY.join('/')}`);
151
+ if (!s.bug.environment) err(`story ${id}: bug 缺少 environment(什么环境能复现)`);
152
+ if (!s.bug.steps) err(`story ${id}: bug 缺少 steps(怎么复现)`);
153
+ }
154
+ }
155
+
69
156
  if ((status === 'ready' || STORY_FLOW.indexOf(status) > 1) && (!acceptance || acceptance.length === 0))
70
- errors.push(`${s.file}: 状态 ${status} 但验收标准为空`);
71
- if (sprintId && (!acceptance || acceptance.length === 0))
72
- errors.push(`${s.file}: 已进 sprint 但无验收标准(不满足 ready)`);
157
+ err(`story ${id}: 状态 ${status} 但验收标准为空`);
158
+ for (const [i, a] of (acceptance ?? []).entries()) {
159
+ if (!a.given || !a.when || !a.then) err(`story ${id}: 第 ${i + 1} 条验收标准缺 given/when/then`);
160
+ // 每条验收都要能被某个 task 的测试验证,否则「验收」只是口号
161
+ for (const tid of a.verifyBy ?? []) {
162
+ const hit = taskById.get(tid);
163
+ if (!hit) err(`story ${id}: 第 ${i + 1} 条验收的 verifyBy ${tid} 不存在`);
164
+ else if (hit.story.id !== id) err(`story ${id}: 第 ${i + 1} 条验收的 verifyBy ${tid} 属于 ${hit.story.id}`);
165
+ }
166
+ if (!(a.verifyBy ?? []).length && status !== 'draft') warn(`story ${id}: 第 ${i + 1} 条验收没有 verifyBy(没绑定验证任务)`);
167
+ }
168
+
169
+ // 阻塞 / 依赖
170
+ for (const dep of s.dependsOn ?? []) {
171
+ if (dep === id) err(`story ${id}: 不能依赖自己`);
172
+ else if (!storyById.has(dep)) err(`story ${id}: dependsOn 引用了不存在的 story ${dep}`);
173
+ }
174
+ if (s.blocked) {
175
+ if (!s.blocked.reason) err(`story ${id}: blocked 缺少 reason(堵在哪要说清)`);
176
+ if (!DATE_RE.test(s.blocked.since ?? '')) err(`story ${id}: blocked.since 格式应为 YYYY-MM-DD`);
177
+ if (status === 'done') err(`story ${id}: 已 done 却还标着 blocked`);
178
+ if (sprintId) warn(`story ${id}: 处于 ${sprintId} 但被阻塞(${s.blocked.reason}),站会必须处理`);
179
+ }
180
+
181
+ // 范围变更标记
182
+ if (s.scopeChange) {
183
+ if (!SCOPE_KIND.includes(s.scopeChange.kind)) err(`story ${id}: scopeChange.kind 必须是 ${SCOPE_KIND.join('/')}`);
184
+ if (!DATE_RE.test(s.scopeChange.at ?? '')) err(`story ${id}: scopeChange.at 格式应为 YYYY-MM-DD`);
185
+ else if (s.scopeChange.at > today) warn(`story ${id}: scopeChange.at ${s.scopeChange.at} 在未来`);
186
+ if (!s.scopeChange.reason) warn(`story ${id}: scopeChange 缺少 reason`);
187
+ }
188
+
189
+ if (sprintId) {
190
+ const sp = sprintById.get(sprintId);
191
+ if (!sp) err(`story ${id}: sprintId ${sprintId} 不存在`);
192
+ else if (!(sp.stories ?? []).includes(id))
193
+ err(`story ${id}: 指向 ${sprintId},但该 sprint 的 stories 里没有它(双向引用不一致)`);
194
+ if (!acceptance || acceptance.length === 0) err(`story ${id}: 已进 sprint 但无验收标准(不满足 ready)`);
195
+ }
196
+
197
+ if (s.schedule) {
198
+ const { start, end } = s.schedule;
199
+ if (!DATE_RE.test(start ?? '') || !DATE_RE.test(end ?? '')) err(`story ${id}: schedule 日期格式应为 YYYY-MM-DD`);
200
+ else if (start > end) err(`story ${id}: schedule 起点 ${start} 晚于终点 ${end}`);
201
+ else if (!sprintId) warn(`story ${id}: 未排期却有计划区间 ${start}~${end}`);
202
+ else {
203
+ const sp = sprintById.get(sprintId);
204
+ if (sp && (start < sp.start || end > sp.end))
205
+ warn(`story ${id}: 计划区间 ${start}~${end} 超出 ${sprintId}(${sp.start}~${sp.end})`);
206
+ }
207
+ }
208
+
209
+ if (!Array.isArray(tasks) || tasks.length === 0) warn(`story ${id}: 没有拆 task,TDD 无从落地`);
210
+ for (const t of tasks ?? []) {
211
+ if (!TASK_FLOW.includes(t.status)) err(`task ${t.id}: 非法状态 ${t.status}`);
212
+ if (!['test', 'impl', 'refactor', 'chore'].includes(t.type)) err(`task ${t.id}: 非法类型 ${t.type}`);
213
+ if (!t.title) err(`task ${t.id}: 缺少 title`);
214
+ }
73
215
  if (status === 'done') {
74
216
  for (const t of tasks ?? []) {
75
- if (t.status !== 'done' && t.status !== 'cancelled')
76
- errors.push(`${s.file}: story 已 done 但 task ${t.id} 状态为 ${t.status}`);
217
+ if (t.status !== 'done') err(`story ${id} 已 done 但 task ${t.id} 状态为 ${t.status}`);
77
218
  }
78
219
  }
79
- for (const t of tasks ?? []) {
80
- if (allIds.has(t.id)) errors.push(`${s.file}: task id ${t.id} 重复`);
81
- allIds.add(t.id);
82
- if (!TASK_FLOW.includes(t.status)) errors.push(`${s.file}: task ${t.id} 非法状态 ${t.status}`);
220
+ }
221
+
222
+ // ---- Sprint ----
223
+ let active = 0;
224
+ for (const sp of sprints) {
225
+ if (!sp.name) warn(`sprint ${sp.id}: 缺少 name`);
226
+ if (!DATE_RE.test(sp.start ?? '') || !DATE_RE.test(sp.end ?? '')) err(`sprint ${sp.id}: 起止日期格式应为 YYYY-MM-DD`);
227
+ else if (sp.start > sp.end) err(`sprint ${sp.id}: 起点 ${sp.start} 晚于终点 ${sp.end}`);
228
+ if (!Number.isFinite(sp.capacity)) err(`sprint ${sp.id}: capacity 必须是数字`);
229
+ for (const sid of sp.stories ?? []) {
230
+ const st = storyById.get(sid);
231
+ if (!st) { err(`sprint ${sp.id}: 引用 story ${sid} 不存在`); continue; }
232
+ if (st.sprintId !== sp.id) err(`sprint ${sp.id}: story ${sid} 的 sprintId 是 ${st.sprintId},双向引用不一致`);
83
233
  }
234
+ const committed = (sp.stories ?? []).reduce((a, sid) => a + (storyById.get(sid)?.points ?? 0), 0);
235
+ if (Number.isFinite(sp.capacity) && committed > sp.capacity)
236
+ warn(`sprint ${sp.id}: 承诺 ${committed} 点超过容量 ${sp.capacity} 点`);
237
+ if (sp.start <= today && sp.end >= today) active++;
84
238
  }
239
+ if (active > 1) err(`同时存在 ${active} 个活跃 sprint,只允许一个`);
240
+
241
+ // ---- 迭代 id 规则:R<发布周期>-S<期内冲刺号>,或纯序号 S-01 ----
242
+ {
243
+ const SPRINT_ID_RE = /^(?:R(\d+)-)?S-?(\d{1,2})$/;
244
+ const parsed = [];
245
+ for (const sp of sprints) {
246
+ const m = SPRINT_ID_RE.exec(sp.id ?? '');
247
+ if (!m) { err(`sprint ${sp.id}: id 必须形如 R1-S1(发布周期-冲刺)或 S-01(纯序号)`); continue; }
248
+ parsed.push({ sp, release: m[1] ?? null, seq: Number(m[2]) });
249
+ }
250
+ const styles = new Set(parsed.map(p => (p.release === null ? 'seq' : 'rel')));
251
+ if (styles.size > 1) err('sprint id 风格混用:R<n>-S<n> 与 S-<n> 不许同时出现');
252
+
253
+ const byRelease = new Map();
254
+ for (const p of parsed) {
255
+ const key = p.release ?? 'seq';
256
+ if (!byRelease.has(key)) byRelease.set(key, []);
257
+ byRelease.get(key).push(p);
258
+ }
259
+
260
+ for (const [key, list] of byRelease) {
261
+ const seqs = list.map(p => p.seq).sort((a, b) => a - b);
262
+ const dup = seqs.find((v, i) => i > 0 && v === seqs[i - 1]);
263
+ if (dup) err(`发布周期 ${key} 内冲刺序号重复:S${dup}`);
264
+ else if (seqs.some((v, i) => v !== i + 1))
265
+ err(`发布周期 ${key} 内冲刺序号应为 S1..S${seqs.length} 连续,实际 ${seqs.map(s => `S${s}`).join('/')}`);
266
+ if (list.length > 2) warn(`发布周期 ${key} 内有 ${list.length} 个冲刺(约定一期不超过 2 个)`);
267
+ }
85
268
 
86
- // Task → journal TDD 纪律
87
- const journalByTask = new Map(journals.map(j => [j.data.taskId, j]));
88
- const realTaskIds = new Set();
89
- for (const s of stories) for (const t of s.data.tasks ?? []) realTaskIds.add(t.id);
90
- for (const [tid] of journalByTask) {
91
- if (!realTaskIds.has(tid)) errors.push(`journal: taskId ${tid} 不指向任何真实 task`);
269
+ // 期号越大,起始日期应越晚
270
+ const rels = [...byRelease.entries()]
271
+ .filter(([k]) => k !== 'seq')
272
+ .map(([k, list]) => ({ r: Number(k), start: list.map(p => p.sp.start).sort()[0] }))
273
+ .sort((a, b) => a.r - b.r);
274
+ for (let i = 1; i < rels.length; i++) {
275
+ if (rels[i].start < rels[i - 1].start)
276
+ warn(`发布周期 R${rels[i].r} 起始 ${rels[i].start} 早于 R${rels[i - 1].r}(${rels[i - 1].start}),期号与时间顺序不一致`);
277
+ }
92
278
  }
93
- for (const s of stories) {
94
- for (const t of s.data.tasks ?? []) {
95
- const hasRed = ['red', 'green', 'refactored', 'done'].includes(t.status);
96
- const hasGreen = ['green', 'refactored', 'done'].includes(t.status);
97
- const jShell = journalByTask.get(t.id);
98
- if (!hasRed && t.journal && jShell && (jShell.data.entries ?? []).length > 0)
99
- warns.push(`${t.id}: 尚在 todo 但 journal 已有实施记录(检查是否提前动了手)`);
100
- if (!hasRed) continue;
101
- const j = journalByTask.get(t.id);
102
- if (!j) { errors.push(`task ${t.id}: 状态 ${t.status} 但 journal 缺失`); continue; }
103
- const entries = j.data.entries ?? [];
104
- const firstRedIdx = entries.findIndex(e => e.phase === 'red');
105
- const firstGreenIdx = entries.findIndex(e => e.phase === 'green');
106
- if (firstRedIdx < 0) errors.push(`task ${t.id}: journal 没有 red 记录(先写失败测试的铁律被违反)`);
107
- else if (entries[firstRedIdx].result && !/^fail/i.test(entries[firstRedIdx].result))
108
- errors.push(`task ${t.id}: 第一条 red 记录的 result 应为 fail,实际 "${entries[firstRedIdx].result}"`);
109
- if (hasGreen) {
110
- if (firstGreenIdx < 0) errors.push(`task ${t.id}: 状态已过 red 但 journal 没有 green 记录`);
111
- else if (entries[firstGreenIdx].result && !/^pass/i.test(entries[firstGreenIdx].result))
112
- errors.push(`task ${t.id}: 第一条 green 记录的 result 应为 pass`);
113
- }
114
- if (t.status === 'done') {
115
- const last = entries[entries.length - 1];
116
- if (!last || !last.result || !/^pass/i.test(last.result))
117
- errors.push(`task ${t.id}: 已 done 但 journal 末条记录不是 pass`);
279
+
280
+ // ---- Milestone ----
281
+ for (const m of milestones) {
282
+ if (!m.title) err(`milestone ${m.id}: 缺少 title`);
283
+ if (!['mvp', 'release', 'checkpoint'].includes(m.kind)) err(`milestone ${m.id}: 非法 kind ${m.kind}`);
284
+ if (!MILESTONE_FLOW.includes(m.status)) err(`milestone ${m.id}: 非法状态 ${m.status}`);
285
+ if (!DATE_RE.test(m.due ?? '')) err(`milestone ${m.id}: due 格式应为 YYYY-MM-DD`);
286
+ if (!m.criteria) warn(`milestone ${m.id}: 缺少 criteria(达成口径),没法判断是否 reached`);
287
+ if (m.sprintId) {
288
+ const sp = sprintById.get(m.sprintId);
289
+ if (!sp) err(`milestone ${m.id}: sprintId ${m.sprintId} 不存在`);
290
+ else if (DATE_RE.test(m.due ?? '') && m.due > sp.end)
291
+ warn(`milestone ${m.id}: due ${m.due} 晚于所属 ${m.sprintId} 结束日 ${sp.end}`);
292
+ }
293
+ if (!(m.stories ?? []).length) warn(`milestone ${m.id}: 没有挂任何 story,无法验证达成`);
294
+ for (const sid of m.stories ?? []) {
295
+ if (!storyById.has(sid)) err(`milestone ${m.id}: 引用 story ${sid} 不存在`);
296
+ }
297
+ if (m.status === 'reached') {
298
+ for (const sid of m.stories ?? []) {
299
+ const st = storyById.get(sid);
300
+ if (st && !['done', 'cancelled'].includes(st.status))
301
+ err(`milestone ${m.id} 已 reached 但 story ${sid} 状态为 ${st.status}`);
118
302
  }
119
303
  }
304
+ if (m.status === 'planned' && DATE_RE.test(m.due ?? '') && m.due < today)
305
+ warn(`milestone ${m.id}: 状态 planned 但 due ${m.due} 已过期,应改成 at-risk / missed`);
120
306
  }
121
307
 
122
- // Epic 校验
123
- for (const e of epics) {
124
- for (const sid of e.data.stories ?? []) {
125
- if (!storyIds.has(sid)) errors.push(`${e.file}: 引用 story ${sid} 不存在`);
126
- }
127
- if (e.data.status === 'done') {
128
- for (const sid of e.data.stories ?? []) {
129
- const st = stories.find(s => s.data.id === sid);
130
- if (st && !['done', 'cancelled'].includes(st.data.status))
131
- errors.push(`${e.file}: epic 已 done 但 story ${sid} 状态为 ${st.data.status}`);
132
- }
308
+ // ---- Journal 与 TDD 纪律 ----
309
+ const journalByTask = new Map();
310
+ for (const j of journals) {
311
+ if (!j.taskId) { err('journal: 缺少 taskId'); continue; }
312
+ if (journalByTask.has(j.taskId)) err(`journal: taskId ${j.taskId} 重复`);
313
+ journalByTask.set(j.taskId, j);
314
+ const hit = taskById.get(j.taskId);
315
+ if (!hit) { err(`journal ${j.taskId}: 不指向任何真实 task`); continue; }
316
+ if (j.storyId !== hit.story.id) err(`journal ${j.taskId}: storyId ${j.storyId} 与 task 所属 ${hit.story.id} 不一致`);
317
+ let prev = '';
318
+ for (const e of j.entries ?? []) {
319
+ if (!['red', 'green', 'refactor'].includes(e.phase)) err(`journal ${j.taskId}: 非法 phase ${e.phase}`);
320
+ if (!DATE_RE.test(e.date ?? '')) err(`journal ${j.taskId}: date 格式应为 YYYY-MM-DD`);
321
+ else if (e.date > today) warn(`journal ${j.taskId}: 记录日期 ${e.date} 在未来`);
322
+ if (prev && e.date < prev) err(`journal ${j.taskId}: 记录日期倒序 ${prev} → ${e.date}`);
323
+ prev = e.date;
324
+ if (!e.note) warn(`journal ${j.taskId}: ${e.phase} 缺 note`);
325
+ if (!e.result) warn(`journal ${j.taskId}: ${e.phase} 缺 result`);
133
326
  }
134
327
  }
135
328
 
136
- // Sprint 校验:引用 + 唯一活跃
137
- const today = new Date().toISOString().slice(0, 10);
138
- let active = 0;
139
- for (const sp of sprints) {
140
- for (const sid of sp.data.stories ?? []) {
141
- if (!storyIds.has(sid)) errors.push(`${sp.file}: 引用 story ${sid} 不存在`);
329
+ for (const [tid, { task }] of taskById) {
330
+ const hasRed = ['red', 'green', 'refactored', 'done'].includes(task.status);
331
+ const hasGreen = ['green', 'refactored', 'done'].includes(task.status);
332
+ const j = journalByTask.get(tid);
333
+ if (!hasRed) {
334
+ if (j && (j.entries ?? []).length > 0) warn(`${tid}: 尚在 todo 但 journal 已有实施记录(检查是否提前动了手)`);
335
+ continue;
336
+ }
337
+ if (!j) { err(`task ${tid}: 状态 ${task.status} 但 journal 缺失`); continue; }
338
+ const entries = j.entries ?? [];
339
+ const firstRed = entries.findIndex(e => e.phase === 'red');
340
+ const firstGreen = entries.findIndex(e => e.phase === 'green');
341
+ if (firstRed < 0) err(`task ${tid}: journal 没有 red 记录(先写失败测试的铁律被违反)`);
342
+ else if (entries[firstRed].result && !/^fail/i.test(entries[firstRed].result))
343
+ err(`task ${tid}: 第一条 red 记录的 result 应为 fail,实际 "${entries[firstRed].result}"`);
344
+ if (hasGreen) {
345
+ if (firstGreen < 0) err(`task ${tid}: 状态已过 red 但 journal 没有 green 记录`);
346
+ else if (entries[firstGreen].result && !/^pass/i.test(entries[firstGreen].result))
347
+ err(`task ${tid}: 第一条 green 记录的 result 应为 pass`);
348
+ }
349
+ if (task.status === 'done') {
350
+ const last = entries[entries.length - 1];
351
+ if (!last || !last.result || !/^pass/i.test(last.result))
352
+ err(`task ${tid}: 已 done 但 journal 末条记录不是 pass`);
142
353
  }
143
- if (sp.data.start <= today && sp.data.end >= today) active++;
144
354
  }
145
- if (active > 1) errors.push(`同时存在 ${active} 个活跃 sprint,只允许一个`);
146
355
 
147
- // 输出
356
+ // ---- 输出 ----
148
357
  for (const w of warns) console.warn(` WARN ${w}`);
149
358
  if (errors.length === 0) {
150
- console.log(`✔ 校验通过:${epics.length} epics, ${stories.length} stories, ${sprints.length} sprints, ${journals.length} journals`);
359
+ const taskCount = taskById.size;
360
+ console.log(`✔ 校验通过:${epics.length} epics, ${stories.length} stories (${taskCount} tasks), ${sprints.length} sprints, ${milestones.length} milestones, ${journals.length} journals`);
151
361
  } else {
152
362
  console.error(`✘ 校验失败,共 ${errors.length} 处错误:`);
153
363
  for (const e of errors) console.error(` - ${e}`);