scrum-skills 0.2.4 → 0.2.6

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 (87) hide show
  1. package/.agents/skills/scrum-planning/references/schema.md +84 -1
  2. package/.agents/skills/scrum-report/SKILL.md +1 -0
  3. package/.agents/skills/scrum-tdd/SKILL.md +45 -0
  4. package/README.md +21 -2
  5. package/assets/board/200.html +1 -1
  6. package/assets/board/404.html +1 -1
  7. package/assets/board/_nuxt/{RJtjdZeG.js → 13F3FZJq.js} +1 -1
  8. package/assets/board/_nuxt/{DsNkyZPc.js → 4G46xON2.js} +1 -1
  9. package/assets/board/_nuxt/7DnHvJyr.js +1 -0
  10. package/assets/board/_nuxt/B8d-LQbt.js +13 -0
  11. package/assets/board/_nuxt/{CNbS93H9.js → BGRxZnwm.js} +1 -1
  12. package/assets/board/_nuxt/BMRYIH0I.js +1 -0
  13. package/assets/board/_nuxt/Bi9Axj3V.js +1 -0
  14. package/assets/board/_nuxt/Bk5o5EMW.js +1 -0
  15. package/assets/board/_nuxt/BsNk4K6P.js +1 -0
  16. package/assets/board/_nuxt/BtzBJuQU.js +1 -0
  17. package/assets/board/_nuxt/{DwcwzwSx.js → BvwhGSdZ.js} +1 -1
  18. package/assets/board/_nuxt/{Bh7eUS6e.js → C4MZsmn0.js} +1 -1
  19. package/assets/board/_nuxt/{DJUwhJ8T.js → C7AVymkF.js} +1 -1
  20. package/assets/board/_nuxt/{DrbU50Zd.js → C7wdFLdw.js} +1 -1
  21. package/assets/board/_nuxt/{CUnX_QKi.js → C8xxmt66.js} +1 -1
  22. package/assets/board/_nuxt/{CD0uJuyA.js → CB66hjP7.js} +1 -1
  23. package/assets/board/_nuxt/{D8Bpf7sE.js → CNCQ_RRC.js} +1 -1
  24. package/assets/board/_nuxt/CSF_x8S0.js +1 -0
  25. package/assets/board/_nuxt/CcCIS_4Q.js +1 -0
  26. package/assets/board/_nuxt/{Bu60uYgs.js → CnWxhsmi.js} +1 -1
  27. package/assets/board/_nuxt/{DmKZi-Wo.js → Cp9-r9Yz.js} +1 -1
  28. package/assets/board/_nuxt/CqT7Crsj.js +95 -0
  29. package/assets/board/_nuxt/CqhzGRpQ.js +1 -0
  30. package/assets/board/_nuxt/{D1Yxjnz9.js → CurAl_-I.js} +1 -1
  31. package/assets/board/_nuxt/{CjRYYbsC.js → D2zrOcWn.js} +1 -1
  32. package/assets/board/_nuxt/{CHZ_IHOa.js → DCRsKQUZ.js} +1 -1
  33. package/assets/board/_nuxt/{obNw9aRV.js → DDF25zwT.js} +1 -1
  34. package/assets/board/_nuxt/DIAPFfZA.js +2 -0
  35. package/assets/board/_nuxt/DMZvQhvU.js +1 -0
  36. package/assets/board/_nuxt/{BNKWWG8o.js → DNJ5OliG.js} +1 -1
  37. package/assets/board/_nuxt/{CvFE1CeN.js → DPeEWyPC.js} +1 -1
  38. package/assets/board/_nuxt/DZXukN7r.js +6 -0
  39. package/assets/board/_nuxt/{CyAQx5wv.js → D_dkfQp-.js} +1 -1
  40. package/assets/board/_nuxt/{BSov4ehT.js → Dp5MbiLe.js} +1 -1
  41. package/assets/board/_nuxt/FxbXzpZ6.js +1 -0
  42. package/assets/board/_nuxt/{DaqxYsbV.js → HUproyl7.js} +1 -1
  43. package/assets/board/_nuxt/{C9MiPNdJ.js → J9NS0_M_.js} +1 -1
  44. package/assets/board/_nuxt/builds/latest.json +1 -1
  45. package/assets/board/_nuxt/builds/meta/ceebd758-b616-48d8-872b-0de05659de19.json +1 -0
  46. package/assets/board/_nuxt/burndown.Do6IbM6o.css +1 -0
  47. package/assets/board/_nuxt/{KhBLkIKl.js → dI2CahwK.js} +1 -1
  48. package/assets/board/_nuxt/entry.DUA8YVRM.css +2 -0
  49. package/assets/board/_nuxt/epics.Dmessplu.css +1 -0
  50. package/assets/board/_nuxt/gantt.Dmp00GUo.css +1 -0
  51. package/assets/board/_nuxt/last-change.CaSxA-Z0.css +1 -0
  52. package/assets/board/_nuxt/milestones.U0xKCsEo.css +1 -0
  53. package/assets/board/_nuxt/pages.Dbj2IVk8.css +1 -0
  54. package/assets/board/_nuxt/{BuuAjxsV.js → rV_oonhV.js} +1 -1
  55. package/assets/board/_nuxt/x-PrcVk4.js +1 -0
  56. package/assets/board/burndown/index.html +1 -1
  57. package/assets/board/epics/index.html +1 -1
  58. package/assets/board/gantt/index.html +1 -1
  59. package/assets/board/index.html +1 -1
  60. package/assets/board/last-change/index.html +1 -0
  61. package/assets/board/milestones/index.html +1 -1
  62. package/bin/scrum-skills.mjs +3 -0
  63. package/package.json +41 -40
  64. package/templates/AGENTS.md +15 -2
  65. package/tools/last-change.mjs +326 -0
  66. package/tools/projects.mjs +66 -12
  67. package/tools/serve.mjs +1 -1
  68. package/tools/validate.mjs +124 -0
  69. package/assets/board/_nuxt/0VBLkWnu.js +0 -1
  70. package/assets/board/_nuxt/BTxVIV74.js +0 -1
  71. package/assets/board/_nuxt/C-GYjxwr.js +0 -13
  72. package/assets/board/_nuxt/CDL4GvLz.js +0 -1
  73. package/assets/board/_nuxt/CeiBZIn1.js +0 -95
  74. package/assets/board/_nuxt/Cfk_R787.js +0 -1
  75. package/assets/board/_nuxt/DhhR1iR4.js +0 -1
  76. package/assets/board/_nuxt/HrhQecsY.js +0 -6
  77. package/assets/board/_nuxt/RM4VQc4e.js +0 -1
  78. package/assets/board/_nuxt/builds/meta/ecbff4a6-6538-49b0-9844-64f03ebd103a.json +0 -1
  79. package/assets/board/_nuxt/burndown.BCLMmigw.css +0 -1
  80. package/assets/board/_nuxt/entry.nk2xQ-sa.css +0 -2
  81. package/assets/board/_nuxt/epics.B4-eK1Py.css +0 -1
  82. package/assets/board/_nuxt/fFGyRlCx.js +0 -1
  83. package/assets/board/_nuxt/gantt.DATOqoZf.css +0 -1
  84. package/assets/board/_nuxt/l05SqxhY.js +0 -1
  85. package/assets/board/_nuxt/milestones.z-UuT44X.css +0 -1
  86. package/assets/board/_nuxt/pages.Bi6nErnd.css +0 -1
  87. package/assets/board/_nuxt/uGmbQYdY.js +0 -1
@@ -0,0 +1,326 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 交付说明 agile/last-change.json 的写入策略(零依赖,node >= 18)
4
+ *
5
+ * 规矩只有一条:写之前先看 git。
6
+ * · 文件已提交、工作区干净 → 上一次交付已经进了版本库,整份覆盖没风险;
7
+ * · 还有没提交的改动(未跟踪 / 已暂存 / 改过没提交 / 根本不是 git 仓库)
8
+ * → 直接覆盖会把上一次还没入库的记录冲掉,必须把本次结果并进去。
9
+ *
10
+ * 用法:
11
+ * node tools/last-change.mjs check [dir]
12
+ * 打印状态与建议。退出码:0 可直接写,2 必须先合并,3 读不了文件
13
+ * node tools/last-change.mjs write <记录.json|-> [dir] [--replace]
14
+ * 按状态自动覆盖或合并,写后跑一次 validate,不过就回滚。
15
+ * --replace 无视 git 状态强制整份覆盖(只有确认旧记录不要了才用)
16
+ *
17
+ * 结构规范见 .agents/skills/scrum-planning/references/schema.md 第 4 节。
18
+ */
19
+ import { existsSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
20
+ import { join, resolve } from 'node:path';
21
+ import { execFileSync, spawnSync } from 'node:child_process';
22
+ import { fileURLToPath, pathToFileURL } from 'node:url';
23
+
24
+ const VALIDATOR = fileURLToPath(new URL('./validate.mjs', import.meta.url));
25
+ const REL = 'agile/last-change.json';
26
+
27
+ /** 五种状态 → 一句人话 + 建议动作(write / merge / replace) */
28
+ export const STATE_TEXT = {
29
+ missing: { label: '还没有这个文件', advice: 'write', hint: '第一次写,直接生成即可' },
30
+ committed: { label: '已提交', advice: 'replace', hint: '上一次交付已经在版本库里,可以整份覆盖' },
31
+ dirty: { label: '未提交(有改动)', advice: 'merge', hint: '上一次交付还没入库,覆盖会丢,先合并' },
32
+ untracked: { label: '未提交(未纳入版本库)', advice: 'merge', hint: '文件还没 add 过,覆盖会丢,先合并' },
33
+ 'no-git': { label: '不是 git 仓库', advice: 'merge', hint: '无法判断是否已入库,按未提交处理' },
34
+ };
35
+
36
+ const git = (root, args) =>
37
+ execFileSync('git', ['-C', root, ...args], {
38
+ encoding: 'utf8',
39
+ stdio: ['ignore', 'pipe', 'pipe'],
40
+ timeout: 5000,
41
+ windowsHide: true,
42
+ }).trim();
43
+
44
+ /**
45
+ * 判定 last-change.json 的版本库状态。
46
+ * 返回 { state, advice, file, tracked, dirty, hash?, date?, subject? }
47
+ */
48
+ export function checkLastChange(root = process.cwd()) {
49
+ const dir = resolve(root);
50
+ const file = join(dir, REL);
51
+ const base = { file, state: 'missing', advice: 'write', tracked: false, dirty: false };
52
+ if (!existsSync(file)) return base;
53
+
54
+ let tracked = false;
55
+ try {
56
+ git(dir, ['ls-files', '--error-unmatch', REL]);
57
+ tracked = true;
58
+ } catch (e) {
59
+ const text = `${e.stderr ?? ''}${e.message ?? ''}`;
60
+ // git 没装或不是仓库:都无法判断有没有入库,按「未提交」处理
61
+ if (/not a git repository|ENOENT|is not recognized/i.test(text)) {
62
+ return { ...base, state: 'no-git', advice: 'merge' };
63
+ }
64
+ }
65
+ if (!tracked) return { ...base, state: 'untracked', advice: 'merge' };
66
+
67
+ if (git(dir, ['status', '--porcelain', '--', REL])) {
68
+ return { ...base, state: 'dirty', advice: 'merge', tracked: true, dirty: true };
69
+ }
70
+
71
+ let hash, date, subject;
72
+ try {
73
+ const log = git(dir, ['log', '-1', '--format=%h%x1f%ad%x1f%s', '--date=short', '--', REL]);
74
+ [hash, date, subject] = log.split('\x1f');
75
+ } catch {
76
+ // 已跟踪但查不到 log(浅克隆之类)不影响判断
77
+ }
78
+ return { ...base, state: 'committed', advice: 'replace', tracked: true, hash, date, subject };
79
+ }
80
+
81
+ /** 本地时间戳,精确到秒:2026-10-27 09:05:03。交付记录缺 at 时用它补 */
82
+ export function nowStamp(d = new Date()) {
83
+ const p = n => String(n).padStart(2, '0');
84
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
85
+ }
86
+
87
+ const stampOf = iso => {
88
+ const d = new Date(iso ?? '');
89
+ return Number.isNaN(d.getTime()) ? String(iso ?? '') : nowStamp(d);
90
+ };
91
+
92
+ /**
93
+ * 这个文件在 git 里的提交历史(页面「查看历史」用)。
94
+ * 没有 git / 从没提交过都给空列表,不抛错——空历史是正常状态。
95
+ */
96
+ export function listLastChangeHistory(root = process.cwd(), { limit = 40 } = {}) {
97
+ const dir = resolve(root);
98
+ const state = checkLastChange(dir).state;
99
+ try {
100
+ const out = git(dir, ['log', `-n${limit}`, '--format=%H%x1f%h%x1f%aI%x1f%s', '--', REL]);
101
+ const entries = (out ? out.split('\n').filter(Boolean) : []).map(line => {
102
+ const [hash, short, iso, subject] = line.split('\x1f');
103
+ return { hash, short, date: stampOf(iso), iso, subject };
104
+ });
105
+ return { entries, state };
106
+ } catch (e) {
107
+ return { entries: [], state: 'no-git', message: String(e?.message ?? '') };
108
+ }
109
+ }
110
+
111
+ /** 取某一版交付记录(git show <hash>:agile/last-change.json) */
112
+ export function readLastChangeAt(root = process.cwd(), hash) {
113
+ const dir = resolve(root);
114
+ const id = String(hash ?? '').trim();
115
+ if (!/^[0-9a-f]{7,40}$/i.test(id)) throw new Error('非法的提交号(只接受十六进制 hash)');
116
+
117
+ let raw;
118
+ try {
119
+ raw = git(dir, ['show', `${id}:${REL}`]);
120
+ } catch {
121
+ throw new Error('该提交里取不到 agile/last-change.json(提交不存在,或那时还没这个文件)');
122
+ }
123
+ let doc;
124
+ try {
125
+ doc = JSON.parse(raw);
126
+ } catch {
127
+ throw new Error('该版本的文件不是合法 JSON,没法渲染');
128
+ }
129
+
130
+ let short = id.slice(0, 7);
131
+ let date = '';
132
+ let subject = '';
133
+ try {
134
+ const [h, iso, s] = git(dir, ['log', '-1', '--format=%h%x1f%aI%x1f%s', id, '--', REL]).split('\x1f');
135
+ if (h) short = h;
136
+ date = stampOf(iso);
137
+ subject = s ?? '';
138
+ } catch {
139
+ // 查不到提交说明不影响看内容
140
+ }
141
+ return { hash: id, short, date, subject, doc };
142
+ }
143
+
144
+ const mergeList = (prev, next, keyOf) => {
145
+ const out = [];
146
+ const at = new Map();
147
+ for (const item of prev ?? []) {
148
+ const key = item ? keyOf(item) : null;
149
+ if (!key) { out.push(item); continue; }
150
+ if (at.has(key)) continue;
151
+ at.set(key, out.length);
152
+ out.push(item);
153
+ }
154
+ for (const item of next ?? []) {
155
+ const key = item ? keyOf(item) : null;
156
+ if (!key) { out.push(item); continue; }
157
+ // 同 key:新内容就地替换,位置留在原来的顺序上,读起来才是时间线
158
+ if (at.has(key)) out[at.get(key)] = item;
159
+ else { at.set(key, out.length); out.push(item); }
160
+ }
161
+ return out;
162
+ };
163
+
164
+ const mergeStrings = (prev, next) => {
165
+ const out = [];
166
+ for (const s of [...(prev ?? []), ...(next ?? [])]) {
167
+ if (typeof s !== 'string' || !s.trim()) continue;
168
+ if (!out.includes(s)) out.push(s);
169
+ }
170
+ return out;
171
+ };
172
+
173
+ /**
174
+ * 把下一次交付并进还没提交的旧记录:按 key 去重、新的覆盖旧的、旧的顺序保留。
175
+ * 只处理 lastChange 内层对象(外层 schemaVersion 由调用方补)。
176
+ */
177
+ export function mergeLastChange(prev = {}, next = {}) {
178
+ const out = { ...next };
179
+ if (!out.title) out.title = prev.title;
180
+ if (!out.at) out.at = prev.at;
181
+ if (!out.summary) out.summary = prev.summary;
182
+
183
+ const keep = (key, list) => {
184
+ if (list.length) out[key] = list;
185
+ else delete out[key];
186
+ };
187
+ keep('changes', mergeList(prev.changes, next.changes, c => (c?.kind && c?.id ? `${c.kind}:${c.id}` : null)));
188
+ keep('extras', mergeList(prev.extras, next.extras, x => x?.title ?? null));
189
+ keep('verification', mergeList(prev.verification, next.verification, v => v?.what ?? null));
190
+ keep('preparation', mergeList(prev.preparation, next.preparation, p => p?.step ?? null));
191
+ keep('scenarios', mergeList(prev.scenarios, next.scenarios, s => s?.id ?? s?.title ?? null));
192
+ keep('notes', mergeStrings(prev.notes, next.notes));
193
+ keep('open', mergeStrings(prev.open, next.open));
194
+
195
+ const urls = mergeList(prev.access?.urls, next.access?.urls, u => u?.url ?? null);
196
+ const accounts = mergeList(prev.access?.accounts, next.access?.accounts, a => a?.username ?? null);
197
+ if (urls.length || accounts.length) {
198
+ out.access = {};
199
+ if (urls.length) out.access.urls = urls;
200
+ if (accounts.length) out.access.accounts = accounts;
201
+ } else if (next.access === undefined) {
202
+ delete out.access;
203
+ }
204
+
205
+ // 同一次交付被重复并进来时不要把它的标题再记一遍
206
+ const sameDelivery = prev.title && next.title && prev.title === next.title && prev.at === next.at;
207
+ const older = sameDelivery || !prev.title ? [] : [{ title: prev.title, at: prev.at }];
208
+ const mergedFrom = mergeList(prev.mergedFrom, older, x => `${x?.at ?? ''}|${x?.title ?? ''}`);
209
+ if (mergedFrom.length) {
210
+ out.merged = true;
211
+ out.mergedFrom = mergedFrom;
212
+ } else {
213
+ delete out.merged;
214
+ delete out.mergedFrom;
215
+ }
216
+ return out;
217
+ }
218
+
219
+ /**
220
+ * 按 git 状态写入:已提交 → 覆盖,否则 → 合并。写后校验,失败回滚。
221
+ * next 传 lastChange 内层对象(也接受整份 { schemaVersion, lastChange })。
222
+ */
223
+ export function writeLastChange(root = process.cwd(), next, { validate = true, force = false } = {}) {
224
+ const dir = resolve(root);
225
+ const file = join(dir, REL);
226
+ const input = next?.lastChange ?? next;
227
+ if (!input || typeof input !== 'object') throw new Error('缺少 lastChange 记录(内层对象或整份文件都行)');
228
+ // 交付时间精确到秒:没写就补当下,免得页面上只看到一个日期
229
+ const payload = { ...input };
230
+ if (!payload.at) payload.at = nowStamp();
231
+
232
+ const before = existsSync(file) ? readFileSync(file, 'utf8') : null;
233
+ let prev = null;
234
+ if (before !== null) {
235
+ try {
236
+ prev = JSON.parse(before)?.lastChange ?? null;
237
+ } catch {
238
+ return { action: 'failed', state: checkLastChange(dir).state, file, message: '现有 last-change.json 解析失败,先修好再来写,别直接覆盖' };
239
+ }
240
+ }
241
+
242
+ const state = checkLastChange(dir);
243
+ let action, merged;
244
+ if (!prev) {
245
+ action = 'create';
246
+ merged = payload;
247
+ } else if (force || state.advice === 'replace') {
248
+ action = 'replace';
249
+ merged = payload;
250
+ } else {
251
+ action = 'merge';
252
+ merged = mergeLastChange(prev, payload);
253
+ }
254
+
255
+ writeFileSync(file, JSON.stringify({ schemaVersion: 3, lastChange: merged }, null, 2) + '\n');
256
+ if (!validate) return { action, state: state.state, file, validation: 'skipped', changes: merged.changes?.length ?? 0 };
257
+
258
+ const r = spawnSync(process.execPath, [VALIDATOR, dir], { encoding: 'utf8', timeout: 30000 });
259
+ if (r.status !== 0) {
260
+ if (before === null) rmSync(file, { force: true });
261
+ else writeFileSync(file, before);
262
+ return {
263
+ action: 'rolled-back', state: state.state, file, validation: 'fail',
264
+ message: `写后校验未通过,已回滚:${(r.stdout || r.stderr || '').trim()}`,
265
+ };
266
+ }
267
+ return { action, state: state.state, file, validation: 'pass', changes: merged.changes?.length ?? 0 };
268
+ }
269
+
270
+ function printCheck(state) {
271
+ const info = STATE_TEXT[state.state] ?? STATE_TEXT.missing;
272
+ console.log(`· 文件: ${state.file}`);
273
+ console.log(`· 状态: ${info.label}${state.state === 'committed' && state.hash ? `(${state.hash}${state.date ? ` · ${state.date}` : ''}${state.subject ? ` · ${state.subject}` : ''})` : ''}`);
274
+ console.log(`· 建议: ${info.hint}`);
275
+ }
276
+
277
+ function main() {
278
+ const [cmd, ...rest] = process.argv.slice(2);
279
+ const positional = rest.filter(a => !a.startsWith('--'));
280
+ if (cmd === 'check' || cmd === 'status') {
281
+ const state = checkLastChange(positional[0] ?? process.cwd());
282
+ printCheck(state);
283
+ process.exit(state.advice === 'merge' ? 2 : 0);
284
+ }
285
+ if (cmd === 'write') {
286
+ const src = positional[0];
287
+ if (!src) {
288
+ console.error('用法: node tools/last-change.mjs write <记录.json|-> [目录] [--replace]');
289
+ process.exit(1);
290
+ }
291
+ const dir = resolve(positional[1] ?? process.cwd());
292
+ let raw;
293
+ try {
294
+ raw = src === '-' ? readFileSync(0, 'utf8') : readFileSync(resolve(src), 'utf8');
295
+ } catch (e) {
296
+ console.error(`✘ 读不到记录文件: ${e.message}`);
297
+ process.exit(3);
298
+ }
299
+ let doc;
300
+ try {
301
+ doc = JSON.parse(raw);
302
+ } catch (e) {
303
+ console.error(`✘ 记录文件不是合法 JSON: ${e.message}`);
304
+ process.exit(3);
305
+ }
306
+
307
+ const state = checkLastChange(dir);
308
+ printCheck(state);
309
+ const r = writeLastChange(dir, doc, { force: rest.includes('--replace') });
310
+ if (r.action === 'failed' || r.action === 'rolled-back') {
311
+ console.error(`✘ ${r.message}`);
312
+ process.exit(4);
313
+ }
314
+ const verb = { create: '新建', replace: '整份覆盖', merge: '并入旧记录' }[r.action] ?? r.action;
315
+ console.log(`✔ 已${verb}: ${REL}(changes ${r.changes} 条)`);
316
+ console.log('· 校验: 通过');
317
+ if (r.action === 'merge') console.log('· 旧记录还留着,提交后下次才会整份覆盖');
318
+ return;
319
+ }
320
+
321
+ console.error('用法:\n node tools/last-change.mjs check [目录]\n node tools/last-change.mjs write <记录.json|-> [目录] [--replace]');
322
+ process.exit(1);
323
+ }
324
+
325
+ const entry = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : '';
326
+ if (entry && entry === import.meta.url) main();
@@ -5,6 +5,8 @@ import { execFile, execFileSync } from 'node:child_process';
5
5
  import { promisify } from 'node:util';
6
6
  import { randomBytes } from 'node:crypto';
7
7
  import { fileURLToPath } from 'node:url';
8
+ import { createAgileTools } from './agile-tools.mjs';
9
+ import { checkLastChange, listLastChangeHistory, readLastChangeAt } from './last-change.mjs';
8
10
 
9
11
  const exec = promisify(execFile);
10
12
  const validator = fileURLToPath(new URL('./validate.mjs', import.meta.url));
@@ -58,9 +60,38 @@ try {
58
60
  }
59
61
  }
60
62
 
63
+ /**
64
+ * 故事状态单向车道。看板卡片的「下一步 / 退回」就按这条线走,
65
+ * done 是终点、cancelled 是另一条终点,两者都不许再动。
66
+ */
67
+ export const STORY_FLOW = ['draft', 'ready', 'in-progress', 'review', 'done'];
68
+
69
+ const ACTION_LABEL = { next: '推进到下一状态', prev: '退回上一状态', cancel: '标记为已取消' };
70
+
71
+ /** 按动作算出目标状态;算不出来(已是终点 / 动作不认识)返回 null */
72
+ export function statusAfter(from, action) {
73
+ if (action === 'cancel') return ['draft', 'ready', 'in-progress', 'review'].includes(from) ? 'cancelled' : null;
74
+ if (!STORY_FLOW.includes(from)) return null;
75
+ const i = STORY_FLOW.indexOf(from);
76
+ if (action === 'next') return STORY_FLOW[i + 1] ?? null;
77
+ if (action === 'prev') return STORY_FLOW[i - 1] ?? null;
78
+ return null;
79
+ }
80
+
61
81
  function snapshot(root) {
62
82
  const read = file => JSON.parse(readFileSync(join(root, 'agile', file + '.json'), 'utf8'));
63
- return { backlog: read('backlog'), sprints: read('sprints'), journal: read('journal'), meta: { root, servedAt: new Date().toISOString() } };
83
+ // last-change.json 可选:还没交付过东西就没有它,看板按 null 显示空状态。
84
+ // 这里吞掉解析异常,坏文件交给 validate 报,别让整个 /api/agile 挂掉。
85
+ let lastChange = null;
86
+ try {
87
+ lastChange = read('last-change');
88
+ } catch {}
89
+ // 页面开头要说明「已提交才覆盖、未提交要合并」,所以状态跟着快照一起给出去
90
+ const { state, advice, hash, date, subject } = checkLastChange(root);
91
+ return {
92
+ backlog: read('backlog'), sprints: read('sprints'), journal: read('journal'), lastChange,
93
+ meta: { root, servedAt: new Date().toISOString(), lastChangeGit: { state, advice, hash, date, subject } },
94
+ };
64
95
  }
65
96
 
66
97
  function validate(root) {
@@ -78,18 +109,12 @@ export function createProjects({ root, store = defaultStore, pick = pickDirector
78
109
  const saved = JSON.parse(readFileSync(store, 'utf8'));
79
110
  if (Array.isArray(saved.recent)) recent = saved.recent.filter(x => typeof x === 'string').slice(0, 10);
80
111
  } catch {}
112
+ // 启动目录优先:从哪个目录起服务,看板就打开哪个目录。
113
+ // 最近列表只是「切回上次那个项目」的入口,不许顶替启动目录——
114
+ // 否则在 A 项目里 serve,会悄悄打开上一次的 B 项目数据(JSON 看着像是 A 的,其实全错)。
81
115
  const initial = realpathSync(root);
82
116
  let current = initial;
83
- for (const candidate of [...recent, initial]) {
84
- try {
85
- const path = realpathSync(candidate);
86
- validate(path);
87
- snapshot(path);
88
- current = path;
89
- break;
90
- } catch {}
91
- }
92
- recent = [...new Set([current, ...recent, initial])].slice(0, 10);
117
+ recent = [...new Set([current, ...recent])].slice(0, 10);
93
118
  let selecting = false;
94
119
  const list = () => ({ recent: [...recent], root: current, token });
95
120
  const read = (path = current) => {
@@ -115,7 +140,25 @@ export function createProjects({ root, store = defaultStore, pick = pickDirector
115
140
  return selected ? await open(selected) : null;
116
141
  } finally { selecting = false; }
117
142
  };
118
- return { list, read, open, select };
143
+ // 写盘复用 AI 助手那套「文本补丁 + 写后校验 + 失败回滚」,
144
+ // 不另写一份:改一个状态只差一行字符,校验不过自动还原。
145
+ const tools = createAgileTools({ root: () => current });
146
+ const setStoryStatus = ({ id, action } = {}) => {
147
+ if (!id) throw new Error('缺少故事 id');
148
+ const { backlog } = snapshot(current);
149
+ const story = (backlog.stories ?? []).find(s => s.id === id);
150
+ if (!story) throw new Error(`找不到故事 ${id}`);
151
+ const from = story.status;
152
+ const to = statusAfter(from, action);
153
+ if (!to) throw new Error(`${id} 当前是 ${from},没法${ACTION_LABEL[action] ?? `执行 ${action}`}`);
154
+ const patches = [{ path: `stories.${id}.status`, value: to }];
155
+ // 取消一条还被阻塞的故事:阻塞标记要一起摘掉。
156
+ // 校验器不许「已取消却还标着 blocked」,不摘会整条改动作废。
157
+ if (to === 'cancelled' && story.blocked) patches.push({ op: 'remove', path: `stories.${id}.blocked` });
158
+ tools.patch({ file: 'backlog', patches, reason: `看板卡片操作:${from} → ${to}` });
159
+ return { id, from, to };
160
+ };
161
+ return { list, read, open, select, setStoryStatus };
119
162
  }
120
163
 
121
164
  export async function handleProjectApi(projects, { method, path, headers, body }) {
@@ -128,8 +171,19 @@ export async function handleProjectApi(projects, { method, path, headers, body }
128
171
  try {
129
172
  if (method === 'GET' && url.pathname === '/api/projects') return { status: 200, body: projects.list() };
130
173
  if (method === 'GET' && url.pathname === '/api/agile') return { status: 200, body: projects.read(url.searchParams.get('root') || undefined) };
174
+ // 交付说明的历史版本:只读,直接从 git 里取(不写文件,所以不需要 token)
175
+ if (method === 'GET' && url.pathname === '/api/agile/last-change/history') {
176
+ return { status: 200, body: listLastChangeHistory(projects.list().root) };
177
+ }
178
+ if (method === 'GET' && url.pathname === '/api/agile/last-change/revision') {
179
+ return { status: 200, body: readLastChangeAt(projects.list().root, url.searchParams.get('hash')) };
180
+ }
131
181
  if (method !== 'POST') return { status: 405, body: { error: '不支持的请求方法。' } };
132
182
  if (headers['x-project-token'] !== projects.list().token) return denied();
183
+ // 看板卡片的退回 / 下一步 / 取消:改的是当前打开项目的 backlog.json
184
+ if (url.pathname === '/api/agile/story-status') {
185
+ return { status: 200, body: projects.setStoryStatus({ id: body?.id, action: body?.action }) };
186
+ }
133
187
  if (url.pathname === '/api/projects/select') return { status: 200, body: await projects.select() };
134
188
  if (url.pathname === '/api/projects/open') {
135
189
  if (!projects.list().recent.includes(body?.root)) throw new Error('该项目不在最近列表中,请选择文件夹。');
package/tools/serve.mjs CHANGED
@@ -112,7 +112,7 @@ const server = createServer(async (req, res) => {
112
112
  return;
113
113
  }
114
114
 
115
- if (pathname === '/api/agile' || pathname.startsWith('/api/projects')) {
115
+ if (pathname.startsWith('/api/agile') || pathname.startsWith('/api/projects')) {
116
116
  try {
117
117
  let text = '';
118
118
  for await (const chunk of req) {
@@ -5,6 +5,7 @@
5
5
  * 校验 <目标>/agile/ 下 backlog.json / sprints.json / journal.json:
6
6
  * 引用完整性(双向)、状态机、TDD 纪律、里程碑达成口径、
7
7
  * 需求属性(type/priority/value/缺陷详情/验收覆盖)、依赖无环、阻塞与范围变更。
8
+ * 另外校验可选的 last-change.json(每次交付的汇报):引用、动作枚举、验收口径、调试地址。
8
9
  * 退出码: 0 通过, 1 有错误。
9
10
  */
10
11
  import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
@@ -61,6 +62,23 @@ const PRIORITY = ['P0', 'P1', 'P2', 'P3'];
61
62
  const SEVERITY = ['blocker', 'major', 'minor', 'trivial'];
62
63
  const SCOPE_KIND = ['added-mid-sprint', 'split', 'expanded'];
63
64
  const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
65
+ /**
66
+ * 交付时间:`YYYY-MM-DD` 或 `YYYY-MM-DD HH:mm:ss`(也接受 ISO 的 T 分隔)。
67
+ * 日期必须真实存在(2026-13-01、2026-02-30 都不行),时分秒越界同样拦掉。
68
+ */
69
+ const STAMP_RE = /^(\d{4}-\d{2}-\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?)?$/;
70
+ const stampOk = value => {
71
+ const m = STAMP_RE.exec(typeof value === 'string' ? value : '');
72
+ if (!m) return false;
73
+ const [, date, hh, mm, ss] = m;
74
+ // 用 UTC 构造再比对:本地时区(东八区)下 new Date('…T00:00:00') 转 ISO 会退到前一天
75
+ const [y, mo, da] = date.split('-').map(Number);
76
+ const d = new Date(Date.UTC(y, mo - 1, da));
77
+ if (d.getUTCFullYear() !== y || d.getUTCMonth() !== mo - 1 || d.getUTCDate() !== da) return false;
78
+ if (hh === undefined) return true;
79
+ return Number(hh) <= 23 && Number(mm) <= 59 && Number(ss ?? 0) <= 59;
80
+ };
81
+ const stampDate = value => (typeof value === 'string' ? value.slice(0, 10) : '');
64
82
  // 用本地日期判定「今天」,避免 UTC 偏移导致活跃迭代判定差一天
65
83
  const now = new Date();
66
84
  const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
@@ -367,6 +385,112 @@ for (const [tid, { task }] of taskById) {
367
385
  }
368
386
  }
369
387
 
388
+ // ---- last-change.json(每次交付的汇报,可选文件) ----
389
+ // 文件不存在 = 还没交付过东西,合法;存在就必须能被看板「最后更新」页原样渲染。
390
+ {
391
+ const p = join(AGILE, 'last-change.json');
392
+ if (existsSync(p)) {
393
+ const ACTION = ['added', 'updated', 'removed'];
394
+ const CHECK = ['pass', 'fail', 'partial', 'skipped'];
395
+ let doc = null;
396
+ try {
397
+ doc = JSON.parse(readFileSync(p, 'utf8'));
398
+ } catch (e) {
399
+ err(`agile/last-change.json: JSON 解析失败 - ${e.message}`);
400
+ }
401
+ if (doc) {
402
+ if (doc.schemaVersion !== 3) err(`agile/last-change.json: schemaVersion 应为 3,实际 ${doc.schemaVersion}`);
403
+ const c = doc.lastChange;
404
+ if (!c || typeof c !== 'object' || Array.isArray(c)) {
405
+ err('last-change: 缺少 lastChange 对象(整个交付说明都装在里面)');
406
+ } else {
407
+ if (!c.title) err('last-change: 缺少 title(这次交付做成了什么)');
408
+ if (!stampOk(c.at)) {
409
+ err('last-change: at 格式应为 YYYY-MM-DD HH:mm:ss(时分秒可省,但省了页面上只显示到天)');
410
+ } else if (typeof c.at === 'string' && c.at.length === 10) {
411
+ warn('last-change: at 只有日期,页面只会显示到天;建议写成 YYYY-MM-DD HH:mm:ss');
412
+ } else if (stampDate(c.at) > today) {
413
+ warn(`last-change: at ${c.at} 在未来`);
414
+ }
415
+
416
+ // 1. 故事 / 任务变更清单
417
+ const changes = Array.isArray(c.changes) ? c.changes : [];
418
+ if (!changes.length) warn('last-change: changes 为空(这次交付没碰任何故事或任务?)');
419
+ for (const [i, ch] of changes.entries()) {
420
+ const at = `last-change.changes[${i}]`;
421
+ if (!['story', 'task'].includes(ch.kind)) err(`${at}: kind 必须是 story / task,实际 ${ch.kind}`);
422
+ if (!ch.id) err(`${at}: 缺少 id`);
423
+ if (!ACTION.includes(ch.action)) err(`${at}: action 必须是 ${ACTION.join('/')},实际 ${ch.action}`);
424
+ if (!ch.title) warn(`${at}: 缺 title,看板表格里只剩一个编号`);
425
+ if (ch.files !== undefined && !Array.isArray(ch.files)) err(`${at}: files 必须是数组`);
426
+ if (ch.kind === 'story' && ch.id && !storyById.has(ch.id)) err(`${at}: 故事 ${ch.id} 不存在`);
427
+ if (ch.kind === 'task' && ch.id && !taskById.has(ch.id)) err(`${at}: 任务 ${ch.id} 不存在`);
428
+ }
429
+
430
+ // 2. 额外做的事
431
+ for (const [i, x] of (Array.isArray(c.extras) ? c.extras : []).entries()) {
432
+ if (!x?.title) err(`last-change.extras[${i}]: 缺少 title(做了什么要写清)`);
433
+ if (x?.files !== undefined && !Array.isArray(x.files)) err(`last-change.extras[${i}]: files 必须是数组`);
434
+ }
435
+
436
+ // 3. 验证结果
437
+ for (const [i, v] of (Array.isArray(c.verification) ? c.verification : []).entries()) {
438
+ if (!v?.what) err(`last-change.verification[${i}]: 缺少 what(验的是哪一项)`);
439
+ if (!CHECK.includes(v?.result)) err(`last-change.verification[${i}]: result 必须是 ${CHECK.join('/')},实际 ${v?.result}`);
440
+ }
441
+
442
+ // 4. 账号密码与调试地址
443
+ if (c.access !== undefined) {
444
+ for (const [i, u] of (Array.isArray(c.access?.urls) ? c.access.urls : []).entries()) {
445
+ if (!u?.label) warn(`last-change.access.urls[${i}]: 缺 label,看板只会显示一串地址`);
446
+ if (!/^https?:\/\/\S+$/.test(u?.url ?? '')) err(`last-change.access.urls[${i}]: url 必须是 http(s) 开头的完整地址,实际 ${u?.url}`);
447
+ }
448
+ for (const [i, a] of (Array.isArray(c.access?.accounts) ? c.access.accounts : []).entries()) {
449
+ if (!a?.username) err(`last-change.access.accounts[${i}]: 缺少 username`);
450
+ if (a?.password !== undefined && typeof a.password !== 'string') err(`last-change.access.accounts[${i}]: password 必须是字符串`);
451
+ }
452
+ }
453
+
454
+ // 5. 测试前准备
455
+ for (const [i, s] of (Array.isArray(c.preparation) ? c.preparation : []).entries()) {
456
+ if (!s?.step) err(`last-change.preparation[${i}]: 缺少 step(要做什么)`);
457
+ }
458
+
459
+ // 6. 逐条测试场景
460
+ for (const [i, s] of (Array.isArray(c.scenarios) ? c.scenarios : []).entries()) {
461
+ const at = `场景 ${s?.id ?? `#${i + 1}`}`;
462
+ if (!s?.id) warn(`last-change.scenarios[${i}]: 缺 id,逐条核对时没法引用`);
463
+ if (!s?.title) err(`last-change.${at}: 缺少 title`);
464
+ if (!s?.expect) err(`last-change.${at}: 缺少 expect(验收标准是什么)`);
465
+ if (s?.steps !== undefined && (!Array.isArray(s.steps) || s.steps.some(x => typeof x !== 'string' || !x.trim())))
466
+ err(`last-change.${at}: steps 必须是非空字符串数组(一步一条)`);
467
+ if (Array.isArray(s?.steps) && !s.steps.length) warn(`last-change.${at}: steps 为空,用户照着没法走`);
468
+ }
469
+
470
+ // 合并标记:未提交的旧记录被并进来时才出现
471
+ if (c.merged !== undefined && typeof c.merged !== 'boolean')
472
+ err('last-change.merged: 必须是布尔值');
473
+ if (c.mergedFrom !== undefined) {
474
+ if (!Array.isArray(c.mergedFrom)) err('last-change.mergedFrom: 必须是数组');
475
+ else for (const [i, m] of c.mergedFrom.entries()) {
476
+ if (!m?.title) err(`last-change.mergedFrom[${i}]: 缺少 title(并进来的是哪次交付)`);
477
+ if (m?.at !== undefined && !stampOk(m.at)) err(`last-change.mergedFrom[${i}]: at 格式应为 YYYY-MM-DD HH:mm:ss`);
478
+ }
479
+ if (Array.isArray(c.mergedFrom) && !c.mergedFrom.length) warn('last-change.mergedFrom: 空数组没有意义,删掉它');
480
+ }
481
+ if (c.merged === true && !(Array.isArray(c.mergedFrom) && c.mergedFrom.length))
482
+ warn('last-change.merged 为 true 但没写 mergedFrom,看板讲不清并了哪几次');
483
+
484
+ // 7. 说明 / 遗留
485
+ for (const key of ['notes', 'open']) {
486
+ if (c[key] !== undefined && (!Array.isArray(c[key]) || c[key].some(x => typeof x !== 'string')))
487
+ err(`last-change.${key}: 必须是字符串数组`);
488
+ }
489
+ }
490
+ }
491
+ }
492
+ }
493
+
370
494
  // ---- 输出 ----
371
495
  for (const w of warns) console.warn(` WARN ${w}`);
372
496
  if (errors.length === 0) {
@@ -1 +0,0 @@
1
- import{D as e,E as t,On as n,T as r,Ut as ee,Yt as te,Zn as i,_ as a,b as o,bt as s,er as ne,g as c,k as l,mt as u,o as d,qt as re,tr as f,un as ie,vn as p,y as m}from"./DZo3dYLJ.js";import{S as ae}from"./51OWHj8c.js";import{t as oe}from"./BJW3UkS7.js";import{t as se}from"./fFGyRlCx.js";import{t as ce}from"./CjRYYbsC.js";import{a as h,i as g,r as le}from"#entry";var ue={class:`kpis`},de={class:`kpi tint-lilac`},fe={class:`v`},pe={class:`kpi`},me={class:`v`},he={class:`kpi`},ge={class:`kpi tint-mint`},_e={class:`v`},ve={class:`kpi`},ye={class:`v`},be={key:0,class:`card panel`},xe={class:`panel-hd`},Se={class:`ph-l`},_={class:`sp-name`},v={class:`sub mono`},y={class:`goal`},b={class:`chart-wrap`},x=[`viewBox`],S=[`y1`,`x2`,`y2`],C=[`x`,`y`],w=[`d`],T=[`d`],E=[`points`],D=[`cx`,`cy`],O=[`x1`,`y1`,`x2`,`y2`],k=[`x`,`y`],A=[`x`,`y`],j=[`x1`,`y1`,`x2`,`y2`],Ce=[`cx`,`cy`],we={class:`cells`},Te={class:`cd mono`},Ee={class:`ca`},De={class:`ci dim`},Oe={key:0,class:`scope`},ke={class:`h3`},Ae={class:`tag tag-info`},je={class:`dim`},Me={key:1,class:`callout soft`},Ne={class:`card`},Pe={class:`card-hd`},Fe={class:`tag tag-neutral`},Ie={class:`card-bd`},Le={class:`tbl`},Re=[`onClick`],ze={class:`mono`},Be={class:`mono dim`},Ve={class:`r`},He={class:`r mono`},Ue=980,We=300,M=42,N=g(l({__name:`burndown`,setup(l){let{sprints:g,burndown:N,sprintCommit:P,md:F,now:I,sprintById:L,storyById:Ge,SCOPE_LABEL:R}=h(),z=p(``),Ke=c(()=>[...g.value].reverse().map(e=>({label:`${e.id} · ${e.name}`,value:e.id,icon:`i-lucide-calendar-range`}))),B=c(()=>L.value.get(z.value)??g.value[g.value.length-1]),V=c(()=>B.value?N(B.value):{total:0,days:[]});ee(()=>{z.value&&!L.value.has(z.value)&&(z.value=``),!z.value&&g.value.length&&(z.value=g.value[g.value.length-1].id)});let H=c(()=>V.value.days.length||1),U=c(()=>V.value.total||1),W=e=>M+e*896/(H.value-1||1),G=e=>258-e*216/U.value,K=e=>V.value.days.map((t,n)=>`${W(n)},${G(t[e])}`).join(` `),qe=c(()=>{let e=V.value.days.map((e,t)=>`${W(t)},${G(e.actual)}`).join(` `),t=V.value.days.length-1;return`M${W(0)},${G(0)} L${e.replaceAll(` `,` L`)} L${W(t)},${G(0)} Z`}),q=c(()=>V.value.days.findIndex(e=>e.date===I)),J=c(()=>V.value.days.find(e=>e.date===I)??V.value.days[V.value.days.length-1]),Y=c(()=>J.value?.actual??0),X=c(()=>J.value?.ideal??0),Z=c(()=>Math.round((Y.value-X.value)*10)/10),Q=c(()=>{let e=B.value;return e?(e.stories??[]).map(e=>Ge.value.get(e)).filter(Boolean).filter(t=>t.scopeChange&&t.scopeChange.at>=e.start&&t.scopeChange.at<=e.end).map(e=>({id:e.id,title:e.title,at:e.scopeChange.at,kind:e.scopeChange.kind,reason:e.scopeChange.reason,points:e.points,idx:V.value.days.findIndex(t=>t.date===e.scopeChange.at)})).filter(e=>e.idx>=0):[]}),Je=c(()=>new Set(Q.value.map(e=>e.at))),$=c(()=>[...g.value].reverse().map(e=>{let t=e.start<=I&&e.end>=I;return{id:e.id,name:e.name,period:`${F(e.start)} ~ ${F(e.end)}`,stories:(e.stories??[]).length,commit:P(e),capacity:e.capacity,status:t?`进行中`:e.end<I?`已结束`:`未开始`,tone:t?`tag-primary`:e.end<I?`tag-neutral`:`tag-info`}}));return(ee,c)=>{let l=le,p=se,h=ce,g=oe;return u(),o(`div`,null,[a(`div`,ue,[a(`div`,de,[e(l,{tone:`lilac`,size:`sm`}),c[1]||=a(`p`,{class:`k`},`剩余故事点`,-1),a(`p`,fe,[t(f(n(Y)),1),a(`small`,null,`/ `+f(n(V).total),1)])]),a(`div`,pe,[c[3]||=a(`p`,{class:`k`},`理想剩余(今天)`,-1),a(`p`,me,[t(f(n(X)),1),c[2]||=a(`small`,null,`点`,-1)])]),a(`div`,he,[c[5]||=a(`p`,{class:`k`},`偏差`,-1),a(`p`,{class:i([`v`,{bad:n(Z)>0,good:n(Z)<=0}])},[t(f(n(Z)>0?`+`+n(Z):n(Z)),1),c[4]||=a(`small`,null,`点`,-1)],2)]),a(`div`,ge,[e(l,{tone:`mint`,size:`sm`}),c[6]||=a(`p`,{class:`k`},`承诺 / 容量`,-1),a(`p`,_e,[t(f(n(B)?n(P)(n(B)):0),1),a(`small`,null,`/ `+f(n(B)?.capacity??0),1)])]),a(`div`,ve,[c[8]||=a(`p`,{class:`k`},`范围变更`,-1),a(`p`,ye,[t(f(n(Q).length),1),c[7]||=a(`small`,null,`次`,-1)])])]),n(B)?(u(),o(`div`,be,[a(`div`,xe,[a(`div`,Se,[a(`h2`,_,f(n(B).name),1),a(`p`,v,f(n(B).id)+` · `+f(n(F)(n(B).start))+` ~ `+f(n(F)(n(B).end)),1),a(`p`,y,f(n(B).goal),1)]),e(p,{modelValue:n(z),"onUpdate:modelValue":c[0]||=e=>ie(z)?z.value=e:null,items:n(Ke),icon:`i-lucide-calendar-range`,"aria-label":`选择迭代`,class:`bd-select`},null,8,[`modelValue`,`items`])]),a(`div`,b,[c[10]||=r(`<div class="chart-legend" data-v-4ef34f2c><span class="lg" data-v-4ef34f2c><i class="sw actual" data-v-4ef34f2c></i>实际剩余</span><span class="lg" data-v-4ef34f2c><i class="sw ideal" data-v-4ef34f2c></i>理想线</span><span class="lg" data-v-4ef34f2c><i class="sw scope" data-v-4ef34f2c></i>范围变更</span></div>`,1),(u(),o(`svg`,{viewBox:`0 0 ${Ue} ${We}`,class:`chart`,role:`img`},[c[9]||=r(`<defs data-v-4ef34f2c><linearGradient id="bd-line" x1="0" y1="0" x2="1" y2="0" data-v-4ef34f2c><stop offset="0%" stop-color="#8a63f7" data-v-4ef34f2c></stop><stop offset="100%" stop-color="#c3aefb" data-v-4ef34f2c></stop></linearGradient><linearGradient id="bd-area" x1="0" y1="0" x2="0" y2="1" data-v-4ef34f2c><stop offset="0%" stop-color="#8a63f7" stop-opacity="0.16" data-v-4ef34f2c></stop><stop offset="100%" stop-color="#8a63f7" stop-opacity="0" data-v-4ef34f2c></stop></linearGradient></defs>`,1),a(`g`,null,[(u(),o(d,null,s([0,.25,.5,.75,1],e=>a(`line`,{key:`grid`+e,x1:M,y1:G(n(U)*e),x2:938,y2:G(n(U)*e),stroke:`#f1f0ea`,"stroke-width":`1`},null,8,S)),64)),(u(),o(d,null,s([0,.25,.5,.75,1],e=>a(`text`,{key:`gl`+e,x:32,y:G(n(U)*e)+3.5,"text-anchor":`end`,"font-size":`9.5`,fill:`#b0b4ba`},f(Math.round(n(U)*e)),9,C)),64))]),a(`path`,{d:n(qe),fill:`url(#bd-area)`},null,8,w),a(`path`,{d:`M${W(0)},${G(n(U))} L${W(n(H)-1)},${G(0)}`,stroke:`#cdc9d8`,"stroke-width":`1.4`,"stroke-dasharray":`5 5`,fill:`none`},null,8,T),a(`polyline`,{points:K(`actual`),stroke:`url(#bd-line)`,"stroke-width":`2.6`,fill:`none`,"stroke-linejoin":`round`,"stroke-linecap":`round`},null,8,E),(u(!0),o(d,null,s(n(V).days,(e,t)=>(u(),o(`circle`,{key:e.date,cx:W(t),cy:G(e.actual),r:`3`,fill:`#fff`,stroke:`#8a63f7`,"stroke-width":`1.8`},null,8,D))),128)),n(q)>=0?(u(),o(`line`,{key:0,x1:W(n(q)),y1:34,x2:W(n(q)),y2:G(0),stroke:`#cf4759`,"stroke-dasharray":`3 3`,"stroke-width":`1.2`,opacity:`0.7`},null,8,O)):m(``,!0),n(q)>=0?(u(),o(`text`,{key:1,x:W(n(q)),y:28,"text-anchor":`middle`,"font-size":`9.5`,fill:`#a5382f`},`今天`,8,k)):m(``,!0),(u(!0),o(d,null,s(n(V).days,(e,t)=>te((u(),o(`text`,{key:`t`+e.date,x:W(t),y:286,"text-anchor":`middle`,"font-size":`9.5`,fill:`#b0b4ba`},f(n(F)(e.date)),9,A)),[[ae,t%Math.ceil(n(H)/10)===0]])),128)),(u(!0),o(d,null,s(n(Q),e=>(u(),o(`g`,{key:e.at+e.id},[a(`title`,null,f(e.id)+` `+f(e.title)+`:`+f(n(R)[e.kind])+`(`+f(e.at)+`,`+f(e.points)+` 点)—— `+f(e.reason),1),a(`line`,{x1:W(e.idx),y1:36,x2:W(e.idx),y2:G(0),stroke:`#3f7fd4`,"stroke-dasharray":`4 3`,"stroke-width":`1.2`,opacity:`0.8`},null,8,j),a(`circle`,{cx:W(e.idx),cy:34,r:`3`,fill:`#3f7fd4`},null,8,Ce)]))),128))],8,x))]),a(`div`,we,[(u(!0),o(d,null,s(n(V).days,e=>(u(),o(`div`,{key:`row`+e.date,class:i([`cell`,{today:e.date===n(I),future:e.date>n(I),scope:n(Je).has(e.date)}])},[a(`span`,Te,f(n(F)(e.date)),1),a(`span`,Ee,f(e.actual),1),a(`span`,De,f(e.ideal),1),a(`i`,{class:`underline`,style:ne({width:(n(U)?e.actual/n(U)*100:0)+`%`})},null,4)],2))),128))]),c[12]||=a(`p`,{class:`foot dim`},`每格:日期 / 实际剩余 / 理想剩余`,-1),n(Q).length?(u(),o(`div`,Oe,[a(`p`,ke,[e(h,{name:`i-lucide-git-branch`,class:`size-3`}),t(`本迭代范围变更(`+f(n(Q).length)+`)`,1)]),a(`ul`,null,[(u(!0),o(d,null,s(n(Q),r=>(u(),o(`li`,{key:r.id},[a(`span`,Ae,f(n(R)[r.kind]),1),e(g,{to:`/story/${r.id}`,class:`mono link`},{default:re(()=>[t(f(r.id),1)]),_:2},1032,[`to`]),a(`span`,null,f(r.title),1),a(`span`,je,`· `+f(r.at)+` · `+f(r.points)+` 点 · `+f(r.reason),1)]))),128))]),c[11]||=a(`p`,{class:`foot dim`},`竖线标出的那几天,实际剩余没有按理想线下滑,是插了活而不是团队拖。`,-1)])):m(``,!0)])):(u(),o(`div`,Me,[e(h,{name:`i-lucide-info`,class:`size-4`}),c[13]||=t(`还没有迭代数据。 `,-1)])),a(`div`,Ne,[a(`div`,Pe,[c[14]||=a(`h2`,{class:`h2`},`迭代对照`,-1),a(`span`,Fe,f(n($).length),1),c[15]||=a(`span`,{class:`dim tip`},`点一行切换上面的燃尽图`,-1)]),a(`div`,Ie,[a(`table`,Le,[c[16]||=a(`thead`,null,[a(`tr`,null,[a(`th`,null,`迭代`),a(`th`,null,`名称`),a(`th`,null,`周期`),a(`th`,{class:`r`},`故事`),a(`th`,{class:`r`},`承诺 / 容量`),a(`th`,null,`状态`)])],-1),a(`tbody`,null,[(u(!0),o(d,null,s(n($),e=>(u(),o(`tr`,{key:e.id,class:i({on:e.id===n(z)}),onClick:t=>z.value=e.id},[a(`td`,ze,f(e.id),1),a(`td`,null,f(e.name),1),a(`td`,Be,f(e.period),1),a(`td`,Ve,f(e.stories),1),a(`td`,He,f(e.commit)+`/`+f(e.capacity),1),a(`td`,null,[a(`span`,{class:i([`tag`,e.tone])},f(e.status),3)])],10,Re))),128))])])])])])}}}),[[`__scopeId`,`data-v-4ef34f2c`]]);export{N as default};
@@ -1 +0,0 @@
1
- import{D as e,E as t,On as n,Zn as r,_ as i,b as a,bt as o,er as s,g as c,k as l,mt as u,o as d,qt as ee,tr as f,un as p,v as te,vn as m,y as h}from"./DZo3dYLJ.js";import{t as ne}from"./BJW3UkS7.js";import{t as re}from"./fFGyRlCx.js";import{t as ie}from"./CjRYYbsC.js";import{a as g,i as _,r as ae}from"#entry";var oe={class:`stats`},se={class:`stat stat-mint`},ce={class:`stat-top`},le={class:`stat-label`},ue={class:`stat-pill`},de={class:`stat-value`},fe={class:`stat-sub`},pe={class:`stat-delta`},me={class:`split`},he={class:`spark`},ge=[`title`],_e={class:`stat`},ve={class:`stat-top`},ye={class:`stat-label`},v={class:`stat-pill`},y={class:`stat-value`},b={class:`stat-sub`},x={class:`stat-rows`},S={class:`stat-row`},C={class:`v`},w={class:`stat-row`},T={class:`v`},E={class:`stat-row`},D={class:`v`},O={class:`stat-row`},k={class:`v`},A={class:`stat-foot`},j={class:`row`},M={class:`bar`},N={class:`stat stat-lilac`},P={class:`stat-top`},be={class:`stat-label`},xe={class:`stat-value`},Se={class:`unit`},Ce={class:`stat-sub`},we={class:`stat-rows`},Te={class:`stat-row`},Ee={class:`v`},De={class:`stat-row`},Oe={class:`v`},ke={class:`stat-row`},Ae={class:`v`},je={class:`stat-row`},Me={class:`v`},Ne={class:`stat-foot`},Pe={class:`row`},Fe={class:`bar lilac`},Ie={class:`stat stat-ink`},Le={class:`stat-top`},Re={class:`stat-label`},ze={class:`stat-value`},Be={class:`stat-rows`},Ve={class:`stat-row`},He={class:`v`},Ue={class:`stat-row`},We={class:`v`},Ge={class:`stat-row`},Ke={class:`v`},qe={class:`stat-row`},Je={class:`v`},Ye={class:`stat-foot`},Xe={class:`row`},F={class:`bar on-dark`},Ze={class:`card tb`},Qe={class:`tb-l`},$e={class:`tb-r`},et={class:`mono`},tt={class:`mono`},nt={class:`card board`},rt={class:`lanes`},it={class:`lane-hd`},at={class:`lane-ct`},ot={class:`lane-pts`},st={class:`lane-bd`},ct={class:`sc-top`},lt={class:`sc-id mono`},ut={class:`dim sc-wsjf`},dt=[`title`],ft={class:`sc-title`},pt={class:`sc-meta`},mt={class:`sc-tags`},ht={class:`tag tag-ghost`},gt={key:0,class:`tag tag-neutral mono`},_t=[`title`],vt=[`title`],yt={class:`bar slim sc-bar`},bt={key:0,class:`lane-empty`},I=_(l({__name:`index`,setup(l){let{stories:_,epics:I,states:L,progress:R,milestoneOfStory:z,pointsOf:B,milestones:xt,STATE_LABEL:V,STATE_SHORT:St,STATE_DOT:Ct,TYPE_LABEL:wt,TYPE_COLOR:Tt,TYPE_ICON:Et,PRIORITY_COLOR:Dt,SCOPE_LABEL:H,wsjf:U,blockedStories:W}=g(),G=m(`all`),K=m(`all`),q=m(`rank`),Ot=c(()=>[{label:`全部史诗`,value:`all`,icon:`i-lucide-layers`},...I.value.map(e=>({label:`${e.id} ${e.title}`,value:e.id,icon:`i-lucide-layer-group`}))]),kt=c(()=>[{label:`全部里程碑`,value:`all`,icon:`i-lucide-flag`},...xt.value.map(e=>({label:`${e.id} ${e.title}`,value:e.id,icon:e.kind===`mvp`?`i-lucide-star`:`i-lucide-map-pin`}))]),At=[{label:`默认顺序`,value:`rank`,icon:`i-lucide-list-ordered`},{label:`按优先级 P0 → P3`,value:`priority`,icon:`i-lucide-alert-circle`},{label:`按 WSJF 价值/点数`,value:`wsjf`,icon:`i-lucide-scale`},{label:`按点数`,value:`points`,icon:`i-lucide-signal`}],J={P0:0,P1:1,P2:2,P3:3},Y=c(()=>{let e=_.value.filter(e=>!(G.value!==`all`&&e.epicId!==G.value||K.value!==`all`&&!z(e.id).some(e=>e.id===K.value)));return q.value===`priority`?[...e].sort((e,t)=>(J[e.priority]??9)-(J[t.priority]??9)||U(t)-U(e)):q.value===`wsjf`?[...e].sort((e,t)=>U(t)-U(e)):q.value===`points`?[...e].sort((e,t)=>t.points-e.points):e}),X=e=>Y.value.filter(t=>t.status===e),jt=e=>B(X(e).map(e=>e.id)),Z=c(()=>{let e=Y.value,t=e.filter(e=>e.status===`done`),n=e.filter(e=>e.status===`in-progress`||e.status===`review`),r=e.filter(e=>e.status===`ready`||e.status===`draft`),i=e.filter(e=>e.status===`cancelled`),a=e.filter(e=>e.status!==`cancelled`),o=e.flatMap(e=>e.tasks??[]),s=e=>o.filter(t=>t.status===e).length;return{count:e.length,points:e.reduce((e,t)=>e+t.points,0),liveCount:a.length,livePoints:a.reduce((e,t)=>e+t.points,0),donePoints:t.reduce((e,t)=>e+t.points,0),done:t.length,running:n.length,idle:r.length,cancelled:i.length,cancelledPoints:i.reduce((e,t)=>e+t.points,0),tasks:o.length,tasksDone:s(`done`),taskRed:s(`red`),taskGreen:s(`green`),taskRefactor:s(`refactored`),taskTodo:s(`todo`),bugs:e.filter(e=>e.type===`bug`).length,debt:e.filter(e=>e.type===`debt`).length,scope:e.filter(e=>e.scopeChange).length}}),Q=c(()=>Z.value.livePoints?Math.round(Z.value.donePoints/Z.value.livePoints*100):0),Mt=c(()=>Z.value.liveCount?Math.round(Z.value.livePoints/Z.value.liveCount*10)/10:0),$=c(()=>Z.value.tasks?Math.round(Z.value.tasksDone/Z.value.tasks*100):0),Nt=c(()=>W.value.length+Z.value.bugs),Pt=c(()=>Math.max(1,...L.map(e=>X(e).length))),Ft=e=>8+Math.round(X(e).length/Pt.value*34),It=c(()=>{if(G.value===`all`)return`全部史诗`;let e=I.value.find(e=>e.id===G.value);return e?e.title.slice(0,6):`全部史诗`}),Lt=e=>z(e).filter(e=>e.kind===`mvp`);return(c,l)=>{let m=ae,g=ie,_=re,I=ne;return u(),a(`div`,null,[i(`div`,oe,[i(`article`,se,[e(m,{tone:`mint`}),i(`div`,ce,[i(`span`,le,[e(g,{name:`i-lucide-gauge`,class:`size-3.5`}),l[3]||=t(`看板完成度`,-1)]),i(`span`,ue,f(n(It)),1)]),i(`div`,de,[t(f(n(Q)),1),l[4]||=i(`span`,{class:`unit`},`%`,-1)]),i(`div`,fe,[i(`span`,pe,[e(g,{name:`i-lucide-check`,class:`size-3`}),t(f(n(Z).donePoints)+` 点`,1)]),t(` 已交付 · 共 `+f(n(Z).livePoints)+` 点 `,1)]),i(`div`,me,[i(`div`,null,[l[5]||=i(`span`,{class:`k`},`完成故事`,-1),i(`b`,null,[t(f(n(Z).done),1),i(`small`,null,`/`+f(n(Z).liveCount),1)])]),i(`div`,null,[l[7]||=i(`span`,{class:`k`},`剩余点`,-1),i(`b`,null,[t(f(n(Z).livePoints-n(Z).donePoints),1),l[6]||=i(`small`,null,`点`,-1)])])]),i(`div`,he,[(u(!0),a(d,null,o(n(L),e=>(u(),a(`div`,{key:e,class:`spark-col`,title:`${n(V)[e]}:${X(e).length} 个`},[i(`div`,{class:r([`spark-bar`,{done:e===`done`,cancel:e===`cancelled`}]),style:s({height:Ft(e)+`px`})},null,6),i(`span`,null,f(n(St)[e]),1)],8,ge))),128))])]),i(`article`,_e,[e(m,{tone:`green`}),i(`div`,ve,[i(`span`,ye,[e(g,{name:`i-lucide-book-open`,class:`size-3.5`}),l[8]||=t(`故事`,-1)]),i(`span`,v,f(Math.round(n(Mt)))+` 点/个`,1)]),i(`div`,y,[t(f(n(Z).count),1),l[9]||=i(`span`,{class:`unit`},`个`,-1)]),i(`div`,b,`共 `+f(n(Z).livePoints)+` 点 · 任务 `+f(n(Z).tasks)+` 个`,1),i(`div`,x,[i(`div`,S,[l[10]||=i(`span`,{class:`k`},[i(`i`,{class:`dot`,style:{background:`#6aa844`}}),t(`已完成`)],-1),i(`span`,C,f(n(Z).done),1)]),i(`div`,w,[l[11]||=i(`span`,{class:`k`},[i(`i`,{class:`dot`,style:{background:`#3f7fd4`}}),t(`进行中`)],-1),i(`span`,T,f(n(Z).running),1)]),i(`div`,E,[l[12]||=i(`span`,{class:`k`},[i(`i`,{class:`dot`,style:{background:`#b6bac1`}}),t(`未开始`)],-1),i(`span`,D,f(n(Z).idle),1)]),i(`div`,O,[l[13]||=i(`span`,{class:`k`},[i(`i`,{class:`dot`,style:{background:`#cf4759`}}),t(`已取消`)],-1),i(`span`,k,f(n(Z).cancelled)+f(n(Z).cancelledPoints?` · ${n(Z).cancelledPoints} 点`:``),1)])]),i(`div`,A,[i(`div`,j,[i(`span`,null,`任务完成 `+f(n(Z).tasksDone)+`/`+f(n(Z).tasks),1),i(`span`,null,f(n($))+`%`,1)]),i(`div`,M,[i(`i`,{style:s({width:n($)+`%`})},null,4)])])]),i(`article`,N,[e(m,{tone:`lilac`}),i(`div`,P,[i(`span`,be,[e(g,{name:`i-lucide-list-checks`,class:`size-3.5`}),l[14]||=t(`TDD 任务`,-1)]),l[15]||=i(`span`,{class:`stat-pill`},`红 → 绿 → 重构`,-1)]),i(`div`,xe,[t(f(n(Z).tasksDone),1),i(`span`,Se,`/ `+f(n(Z).tasks)+` 项`,1)]),i(`div`,Ce,`已完成任务占 `+f(n($))+`%`,1),i(`div`,we,[i(`div`,Te,[l[16]||=i(`span`,{class:`k`},[i(`i`,{class:`dot`,style:{background:`#cf4759`}}),t(`红(测试失败)`)],-1),i(`span`,Ee,f(n(Z).taskRed),1)]),i(`div`,De,[l[17]||=i(`span`,{class:`k`},[i(`i`,{class:`dot`,style:{background:`#6aa844`}}),t(`绿(测试通过)`)],-1),i(`span`,Oe,f(n(Z).taskGreen),1)]),i(`div`,ke,[l[18]||=i(`span`,{class:`k`},[i(`i`,{class:`dot`,style:{background:`#6d4ff0`}}),t(`已重构`)],-1),i(`span`,Ae,f(n(Z).taskRefactor),1)]),i(`div`,je,[l[19]||=i(`span`,{class:`k`},[i(`i`,{class:`dot`,style:{background:`#b6bac1`}}),t(`待办`)],-1),i(`span`,Me,f(n(Z).taskTodo),1)])]),i(`div`,Ne,[i(`div`,Pe,[i(`span`,null,`已完成 `+f(n(Z).tasksDone)+`/`+f(n(Z).tasks),1),i(`span`,null,f(n($))+`%`,1)]),i(`div`,Fe,[i(`i`,{style:s({width:n($)+`%`})},null,4)])])]),i(`article`,Ie,[e(m,{tone:`ink`}),i(`div`,Le,[i(`span`,Re,[e(g,{name:`i-lucide-shield-alert`,class:`size-3.5`}),l[20]||=t(`风险面`,-1)]),l[21]||=i(`span`,{class:`stat-pill`},`需处理`,-1)]),i(`div`,ze,[t(f(n(Nt)),1),l[22]||=i(`span`,{class:`unit`},`项`,-1)]),l[28]||=i(`div`,{class:`stat-sub`},`阻塞故事与缺陷合计`,-1),i(`div`,Be,[i(`div`,Ve,[l[23]||=i(`span`,{class:`k`},`阻塞故事`,-1),i(`span`,He,f(n(W).length),1)]),i(`div`,Ue,[l[24]||=i(`span`,{class:`k`},`缺陷`,-1),i(`span`,We,f(n(Z).bugs),1)]),i(`div`,Ge,[l[25]||=i(`span`,{class:`k`},`技术债`,-1),i(`span`,Ke,f(n(Z).debt),1)]),i(`div`,qe,[l[26]||=i(`span`,{class:`k`},`范围变更`,-1),i(`span`,Je,f(n(Z).scope),1)])]),i(`div`,Ye,[i(`div`,Xe,[l[27]||=i(`span`,null,`完成度`,-1),i(`span`,null,f(n(Q))+`%`,1)]),i(`div`,F,[i(`i`,{style:s({width:n(Q)+`%`})},null,4)])])])]),i(`div`,Ze,[i(`div`,Qe,[l[29]||=i(`span`,{class:`lbl`},`筛选`,-1),e(_,{modelValue:n(G),"onUpdate:modelValue":l[0]||=e=>p(G)?G.value=e:null,items:n(Ot),icon:`i-lucide-layers`,"aria-label":`按史诗筛选`},null,8,[`modelValue`,`items`]),e(_,{modelValue:n(K),"onUpdate:modelValue":l[1]||=e=>p(K)?K.value=e:null,items:n(kt),icon:`i-lucide-flag`,"aria-label":`按里程碑筛选`},null,8,[`modelValue`,`items`]),e(_,{modelValue:n(q),"onUpdate:modelValue":l[2]||=e=>p(q)?q.value=e:null,items:At,icon:`i-lucide-arrow-up-down`,"aria-label":`排序方式`},null,8,[`modelValue`])]),i(`div`,$e,[i(`span`,et,f(n(Y).length),1),l[30]||=t(` 个故事 `,-1),l[31]||=i(`i`,{class:`sep`},null,-1),i(`span`,tt,f(n(B)(n(Y).map(e=>e.id))),1),l[32]||=t(` 点 `,-1)])]),i(`div`,nt,[i(`div`,rt,[(u(!0),a(d,null,o(n(L),c=>(u(),a(`section`,{key:c,class:`lane`},[i(`header`,it,[i(`i`,{class:`lane-dot`,style:s({background:n(Ct)[c]})},null,4),i(`b`,null,f(n(V)[c]),1),i(`span`,at,f(X(c).length),1),i(`span`,ot,f(jt(c))+` 点`,1)]),i(`div`,st,[(u(!0),a(d,null,o(X(c),c=>(u(),te(I,{key:c.id,to:`/story/${c.id}`,class:`scard`},{default:ee(()=>[i(`div`,ct,[i(`span`,lt,f(c.id),1),i(`span`,{class:r([`tag`,`tag-${n(Dt)[c.priority]}`])},f(c.priority),3),i(`span`,ut,`WSJF `+f(n(U)(c)),1),c.blocked?(u(),a(`span`,{key:0,class:`tag tag-error tag-dot`,title:`阻塞中:${c.blocked.reason}(自 ${c.blocked.since})`},`阻塞`,8,dt)):h(``,!0)]),i(`p`,ft,f(c.title),1),i(`div`,pt,[i(`span`,null,f(c.points)+` 点`,1),l[33]||=i(`span`,{class:`dim`},`·`,-1),i(`span`,null,`任务 `+f(n(R)(c).done)+`/`+f(n(R)(c).total),1)]),i(`div`,mt,[i(`span`,{class:r([`tag`,`tag-${n(Tt)[c.type]}`])},[e(g,{name:n(Et)[c.type],class:`size-3`},null,8,[`name`]),t(f(n(wt)[c.type]),1)],2),i(`span`,ht,f(c.epicId),1),c.sprintId?(u(),a(`span`,gt,f(c.sprintId),1)):h(``,!0),(u(!0),a(d,null,o(Lt(c.id),e=>(u(),a(`span`,{key:e.id,class:`tag tag-mint`,title:`${e.id} ${e.title} · ${e.criteria}`},`MVP`,8,_t))),128)),c.scopeChange?(u(),a(`span`,{key:1,class:`tag tag-info`,title:`${n(H)[c.scopeChange.kind]}(${c.scopeChange.at}):${c.scopeChange.reason}`},f(n(H)[c.scopeChange.kind]),9,vt)):h(``,!0)]),i(`div`,yt,[i(`i`,{style:s({width:n(R)(c).pct+`%`})},null,4)])]),_:2},1032,[`to`]))),128)),X(c).length?h(``,!0):(u(),a(`p`,bt,`暂无故事`))])]))),128))])])])}}}),[[`__scopeId`,`data-v-994b0e20`]]);export{I as default};