scrum-skills 0.1.2 → 0.2.1

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 (49) 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/LICENSE +21 -0
  7. package/README.md +132 -0
  8. package/assets/board/200.html +1 -0
  9. package/assets/board/404.html +1 -0
  10. package/assets/board/_nuxt/4lorSwrA.js +1 -0
  11. package/assets/board/_nuxt/7WA2NiBu.js +1 -0
  12. package/assets/board/_nuxt/BMkghBSN.js +6 -0
  13. package/assets/board/_nuxt/BO7jzDkR.js +1 -0
  14. package/assets/board/_nuxt/BZ3zxHIj.js +1 -0
  15. package/assets/board/_nuxt/BhF-44v-.js +13 -0
  16. package/assets/board/_nuxt/Brv_Bdja.js +3 -0
  17. package/assets/board/_nuxt/C3UUYAQd.js +1 -0
  18. package/assets/board/_nuxt/CJAcsUQO.js +3 -0
  19. package/assets/board/_nuxt/CNsYxQaQ.js +1 -0
  20. package/assets/board/_nuxt/CRaaiMq0.js +1 -0
  21. package/assets/board/_nuxt/CW444i7s.js +1 -0
  22. package/assets/board/_nuxt/CfONUjDo.js +1 -0
  23. package/assets/board/_nuxt/DK3Fl9T5.js +1 -0
  24. package/assets/board/_nuxt/DKNZ_ohS.js +1 -0
  25. package/assets/board/_nuxt/DuWpPgcw.js +3 -0
  26. package/assets/board/_nuxt/LjrS__tx.js +1 -0
  27. package/assets/board/_nuxt/_id_.DhbZaLbc.css +1 -0
  28. package/assets/board/_nuxt/builds/latest.json +1 -0
  29. package/assets/board/_nuxt/builds/meta/2c1d27b2-e682-4213-b9a1-85509dcba982.json +1 -0
  30. package/assets/board/_nuxt/burndown.BENzf335.css +1 -0
  31. package/assets/board/_nuxt/entry.CRMqPpqu.css +2 -0
  32. package/assets/board/_nuxt/epics.BeC881O2.css +1 -0
  33. package/assets/board/_nuxt/error-404.BFmXOnCT.css +1 -0
  34. package/assets/board/_nuxt/error-500.BZ-DdjQ6.css +1 -0
  35. package/assets/board/_nuxt/gantt.B1Ztrpq6.css +1 -0
  36. package/assets/board/_nuxt/laFvd4Y3.js +16 -0
  37. package/assets/board/_nuxt/milestones.B2QTaR0Y.css +1 -0
  38. package/assets/board/_nuxt/pages.DuMz_V7L.css +1 -0
  39. package/assets/board/burndown/index.html +1 -0
  40. package/assets/board/epics/index.html +1 -0
  41. package/assets/board/gantt/index.html +1 -0
  42. package/assets/board/index.html +1 -0
  43. package/assets/board/milestones/index.html +1 -0
  44. package/bin/scrum-skills.mjs +17 -5
  45. package/docs/board.png +0 -0
  46. package/package.json +24 -3
  47. package/templates/AGENTS.md +25 -11
  48. package/tools/serve.mjs +88 -143
  49. package/tools/validate.mjs +319 -109
package/tools/serve.mjs CHANGED
@@ -1,166 +1,111 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * 敏捷数据可视化 server(零依赖,node >= 18)
3
+ * 敏捷看板 server(零依赖,node >= 18)
4
4
  * 用法: node tools/serve.mjs [目标目录] [port] 目录默认 cwd,端口默认 4173
5
- * 页面: / 看板 | /burndown 燃尽图 | /story/:id 追溯链
6
- * 校验不通过时拒绝出报表,提示先跑 validate
5
+ *
6
+ * 它只做两件事:
7
+ * 1. /api/agile 把目标目录 agile/*.json(三文件结构)吐给前端
8
+ * 2. 其余路径 托管 Nuxt 静态产物(assets/board),SPA 回退 index.html
9
+ * 页面渲染全部由 Nuxt 产物负责,这里没有任何手写 HTML。
10
+ * 校验不通过时拒绝出报表,提示先跑 validate。
7
11
  */
8
- import { readdirSync, readFileSync, existsSync } from 'node:fs';
9
- import { join, basename, resolve } from 'node:path';
12
+ import { readFileSync, existsSync, statSync, createReadStream } from 'node:fs';
13
+ import { join, resolve, extname, normalize, sep } from 'node:path';
10
14
  import { execFileSync } from 'node:child_process';
15
+ import { createServer } from 'node:http';
16
+ import { fileURLToPath } from 'node:url';
11
17
 
12
- const PKG_ROOT = new URL('..', import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1');
18
+ const PKG_ROOT = fileURLToPath(new URL('..', import.meta.url));
13
19
  const TARGET = resolve(process.argv[2] || process.cwd());
14
20
  const PORT = Number(process.argv[3] || 4173);
15
21
  const AGILE = join(TARGET, 'agile');
16
22
 
17
- // ---- 数据加载 ----
18
- function readJson(dir) {
19
- const out = [];
20
- if (!existsSync(dir)) return out;
21
- for (const f of readdirSync(dir)) if (f.endsWith('.json'))
22
- out.push(JSON.parse(readFileSync(join(dir, f), 'utf8')));
23
- return out;
23
+ // 静态产物:优先用包内 assets/board,仓库内开发时退化到 apps/board/.output/public
24
+ const BOARD_DIR = [
25
+ join(PKG_ROOT, 'assets/board'),
26
+ join(PKG_ROOT, 'apps/board/.output/public'),
27
+ ].find(d => existsSync(join(d, 'index.html')));
28
+
29
+ if (!BOARD_DIR) {
30
+ console.error('✘ 找不到看板静态产物(assets/board/index.html)。请在仓库里先跑 pnpm --dir apps/board run generate');
31
+ process.exit(1);
32
+ }
33
+
34
+ try {
35
+ execFileSync(process.execPath, [join(PKG_ROOT, 'tools/validate.mjs'), TARGET], { stdio: 'pipe' });
36
+ } catch (e) {
37
+ console.error(`✘ ${TARGET} 下的数据校验未通过,拒绝出报表。请先修复:\n${e.stdout ?? e.message}`);
38
+ process.exit(1);
24
39
  }
25
- function load() {
40
+
41
+ const MIME = {
42
+ '.html': 'text/html; charset=utf-8',
43
+ '.js': 'text/javascript; charset=utf-8',
44
+ '.mjs': 'text/javascript; charset=utf-8',
45
+ '.css': 'text/css; charset=utf-8',
46
+ '.json': 'application/json; charset=utf-8',
47
+ '.svg': 'image/svg+xml',
48
+ '.png': 'image/png',
49
+ '.jpg': 'image/jpeg',
50
+ '.webp': 'image/webp',
51
+ '.ico': 'image/x-icon',
52
+ '.woff2': 'font/woff2',
53
+ '.woff': 'font/woff',
54
+ '.ttf': 'font/ttf',
55
+ '.map': 'application/json; charset=utf-8',
56
+ };
57
+
58
+ function readAgile() {
59
+ const read = f => JSON.parse(readFileSync(join(AGILE, f), 'utf8'));
26
60
  return {
27
- epics: readJson(join(AGILE, 'backlog/epics')),
28
- stories: readJson(join(AGILE, 'backlog/stories')),
29
- sprints: readJson(join(AGILE, 'sprints')),
30
- journals: readJson(join(AGILE, 'journal')),
61
+ backlog: read('backlog.json'),
62
+ sprints: read('sprints.json'),
63
+ journal: read('journal.json'),
64
+ meta: { root: TARGET, servedAt: new Date().toISOString() },
31
65
  };
32
66
  }
33
67
 
34
- // ---- 聚合 ----
35
- function storyCard(s) {
36
- const tasks = s.tasks ?? [];
37
- const done = tasks.filter(t => t.status === 'done').length;
38
- return { id: s.id, title: s.title, points: s.points, sprintId: s.sprintId,
39
- epicId: s.epicId, done, total: tasks.length };
68
+ function sendFile(res, file) {
69
+ res.writeHead(200, {
70
+ 'content-type': MIME[extname(file).toLowerCase()] ?? 'application/octet-stream',
71
+ 'cache-control': 'no-cache',
72
+ });
73
+ createReadStream(file).pipe(res);
40
74
  }
41
- function burndown(sprints, stories, journals) {
42
- const today = new Date().toISOString().slice(0, 10);
43
- const sp = sprints.filter(s => s.start <= today && s.end >= today)[0] || sprints[0];
44
- if (!sp) return null;
45
- const total = (sp.stories ?? []).reduce((a, sid) => {
46
- const st = stories.find(s => s.id === sid); return a + (st?.points ?? 0);
47
- }, 0);
48
- // 每个 task 的 done 日期取其 journal 最后一条 pass 的 date
49
- const doneDate = new Map();
50
- for (const j of journals) {
51
- const entries = (j.entries ?? []).filter(e => /^pass/i.test(e.result || ''));
52
- if (entries.length) doneDate.set(j.taskId, entries[entries.length - 1].date);
53
- }
54
- const days = [];
55
- for (let d = new Date(sp.start); d <= new Date(sp.end); d.setDate(d.getDate() + 1)) {
56
- const ds = d.toISOString().slice(0, 10);
57
- let burned = 0;
58
- for (const sid of sp.stories ?? []) {
59
- const st = stories.find(s => s.id === sid);
60
- if (!st || st.status !== 'done') continue;
61
- const nDone = (st.tasks ?? []).filter(t => doneDate.get(t.id) <= ds).length;
62
- burned += (st.points || 0) * ((st.tasks?.length || 1) > 0 ? nDone / st.tasks.length : 0);
75
+
76
+ const server = createServer((req, res) => {
77
+ const url = new URL(req.url, 'http://x');
78
+ const pathname = decodeURIComponent(url.pathname);
79
+
80
+ if (pathname === '/api/agile') {
81
+ try {
82
+ const body = JSON.stringify(readAgile());
83
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
84
+ res.end(body);
85
+ } catch (e) {
86
+ res.writeHead(500, { 'content-type': 'application/json; charset=utf-8' });
87
+ res.end(JSON.stringify({ error: String(e.message) }));
63
88
  }
64
- days.push({ date: ds, remaining: Math.round((total - burned) * 10) / 10 });
89
+ return;
65
90
  }
66
- return { sprint: sp, total, days };
67
- }
68
-
69
- // ---- 页面(内联极简 HTML+SVG,无外部依赖) ----
70
- const CSS = `<style>
71
- *{box-sizing:border-box} body{font:14px/1.6 system-ui,sans-serif;margin:0;background:#f5f6fb;color:#1b1c20}
72
- header{background:linear-gradient(135deg,#4a7cf6,#7a5af5);color:#fff;padding:18px 28px}
73
- header a{color:#fff;margin-right:18px;text-decoration:none;opacity:.9}
74
- main{padding:24px;max-width:1200px;margin:0 auto}
75
- .cols{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:14px}
76
- .col h3{margin:0 0 8px;font-size:13px;color:#666}
77
- .card{background:#fff;border-radius:12px;box-shadow:0 2px 8px rgba(0,0,0,.06);padding:12px;margin-bottom:10px}
78
- .card b{font-size:14px} .meta{font-size:12px;color:#888;margin-top:4px}
79
- .bar{height:5px;background:#e9edfe;border-radius:99px;margin-top:8px;overflow:hidden}
80
- .bar i{display:block;height:100%;background:linear-gradient(90deg,#4a7cf6,#7a5af5)}
81
- .chip{display:inline-block;background:#e9edfe;border:1px solid #d7ddfc;border-radius:99px;padding:0 9px;font-size:11px;margin-right:6px}
82
- .ac{background:#fff;border-radius:12px;box-shadow:0 2px 8px rgba(0,0,0,.06);padding:14px;margin-bottom:12px}
83
- .phase{border-left:4px solid #ccc;padding:8px 14px;margin:8px 0;background:#fff;border-radius:0 10px 10px 0}
84
- .red{border-color:#e05260}.green{border-color:#3aa76d}.refactor{border-color:#4a7cf6}
85
- pre{background:#f0f1f5;padding:8px 12px;border-radius:8px;overflow:auto;font-size:12px}
86
- </style>`;
87
- const page = (title, body) => `<!doctype html><html lang="zh"><head><meta charset="utf-8"><title>${title}</title>${CSS}</head>
88
- <body><header><strong>敏捷看板</strong>
89
- <a href="/">看板</a><a href="/burndown">燃尽图</a></header><main>${body}</main></body></html>`;
90
91
 
91
- function kanbanHtml(d) {
92
- const cols = ['draft', 'ready', 'in-progress', 'review', 'done'];
93
- const label = { draft: '草稿', ready: '就绪', 'in-progress': '进行中', review: '评审', done: '完成' };
94
- return page('看板', `<h2>看板</h2><div class="cols">` + cols.map(c => {
95
- const cards = d.stories.filter(s => (s.status ?? 'draft') === c).map(s => {
96
- const sc = storyCard(s);
97
- const pct = sc.total ? Math.round(sc.done / sc.total * 100) : 0;
98
- return `<div class="card"><span class="chip">${sc.id}</span>${sc.sprintId ? `<span class="chip">${sc.sprintId}</span>` : ''}
99
- <b><a href="/story/${sc.id}">${sc.title}</a></b>
100
- <div class="meta">${sc.epicId ?? ''} · ${sc.points ?? '?'} 点 · 任务 ${sc.done}/${sc.total}</div>
101
- <div class="bar"><i style="width:${pct}%"></i></div></div>`;
102
- }).join('');
103
- return `<div class="col"><h3>${label[c]}(${cards ? d.stories.filter(s => s.status === c).length : 0})</h3>${cards}</div>`;
104
- }).join('') + '</div>');
105
- }
92
+ // 静态文件:防目录穿越 + SPA 回退
93
+ const rel = normalize(pathname).replace(/^([/\\])+/, '');
94
+ const file = join(BOARD_DIR, rel);
95
+ if (!file.startsWith(BOARD_DIR + sep) && file !== BOARD_DIR) {
96
+ res.writeHead(403).end('forbidden');
97
+ return;
98
+ }
99
+ if (pathname !== '/' && existsSync(file) && statSync(file).isFile()) return sendFile(res, file);
106
100
 
107
- function burndownHtml(d) {
108
- const b = burndown(d.sprints, d.stories, d.journals);
109
- if (!b) return page('燃尽图', '<h2>燃尽图</h2><p>暂无 sprint 数据。</p>');
110
- const W = 900, H = 320, P = 40;
111
- const n = b.days.length || 1;
112
- const maxV = b.total || 1;
113
- const x = i => P + i * (W - 2 * P) / (n - 1 || 1);
114
- const y = v => H - P - v * (H - 2 * P) / maxV;
115
- const pts = b.days.map((d, i) => `${x(i)},${y(d.remaining)}`).join(' ');
116
- const ideal = `M${x(0)},${y(b.total)} L${x(n - 1)},${y(0)}`;
117
- const labels = b.days.map((d, i) => i % Math.ceil(n / 10) === 0
118
- ? `<text x="${x(i)}" y="${H - 15}" font-size="10" text-anchor="middle" fill="#888">${d.date.slice(5)}</text>` : '').join('');
119
- return page('燃尽图', `<h2>燃尽图 — ${b.sprint.name}(${b.sprint.start} ~ ${b.sprint.end},总 ${b.total} 点)</h2>
120
- <svg viewBox="0 0 ${W} ${H}" style="max-width:960px;width:100%">
121
- <line x1="${P}" y1="${y(0)}" x2="${W - P}" y2="${y(0)}" stroke="#ccc"/>
122
- <line x1="${P}" y1="${P}" x2="${P}" y2="${y(0)}" stroke="#ccc"/>
123
- <path d="${ideal}" stroke="#c9c9d4" stroke-dasharray="6 5" fill="none"/>
124
- <polyline points="${pts}" stroke="#4a7cf6" stroke-width="2.5" fill="none"/>
125
- ${b.days.map((d, i) => `<circle cx="${x(i)}" cy="${y(d.remaining)}" r="3.5" fill="#7a5af5"/>`).join('')}
126
- ${labels}
127
- </svg><p class="meta">虚线为理想燃尽线;点数据来自 journal 的 pass 日期,缺口说明有 task 未按纪律落库。</p>`);
128
- }
101
+ const index = join(BOARD_DIR, 'index.html');
102
+ if (existsSync(index)) return sendFile(res, index);
129
103
 
130
- function storyHtml(d, id) {
131
- const s = d.stories.find(x => x.id === id);
132
- if (!s) return page('404', `<h2>未找到 ${id}</h2>`);
133
- const ac = (s.acceptance ?? []).map(a =>
134
- `<li><b>Given</b> ${a.given};<b>When</b> ${a.when};<b>Then</b> ${a.then}</li>`).join('');
135
- const tasks = (s.tasks ?? []).map(t => {
136
- const j = d.journals.find(x => x.taskId === t.id);
137
- const entries = (j?.entries ?? []).map(e =>
138
- `<div class="phase ${e.phase}"><b>${e.phase.toUpperCase()}</b> <span class="meta">${e.date} · ${e.result ?? ''}</span>
139
- <div>${e.note}</div>${e.files?.length ? `<div class="meta">文件:${e.files.join(', ')}</div>` : ''}
140
- ${e.testCommand ? `<pre>${e.testCommand}</pre>` : ''}</div>`).join('');
141
- return `<div class="ac"><span class="chip">${t.id}</span><span class="chip">${t.type}</span><span class="chip">${t.status}</span>
142
- <b>${t.title}</b>${entries || '<p class="meta">暂无实施记录</p>'}</div>`;
143
- }).join('');
144
- return page(id, `<h2><span class="chip">${s.id}</span>${s.title}</h2>
145
- <p>${s.narrative ?? ''}</p><p class="meta">${s.epicId ?? ''} · ${s.sprintId ?? '未排期'} · ${s.points ?? '?'} 点 · 状态 ${s.status}</p>
146
- <h3>验收标准</h3><ol>${ac}</ol><h3>任务与实施记录</h3>${tasks}`);
147
- }
104
+ res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }).end('not found');
105
+ });
148
106
 
149
- // ---- server ----
150
- try { execFileSync(process.execPath, [join(PKG_ROOT, 'tools/validate.mjs'), TARGET], { stdio: 'pipe' }); }
151
- catch (e) {
152
- console.error(`✘ ${TARGET} 下的数据校验未通过,拒绝出报表。请先修复:\n` + e.stdout);
153
- process.exit(1);
154
- }
155
- import('node:http').then(({ createServer }) => {
156
- createServer((req, res) => {
157
- const d = load();
158
- const url = new URL(req.url, 'http://x');
159
- let html = null;
160
- if (url.pathname === '/') html = kanbanHtml(d);
161
- else if (url.pathname === '/burndown') html = burndownHtml(d);
162
- else if (url.pathname.startsWith('/story/')) html = storyHtml(d, url.pathname.slice(7));
163
- if (html) { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); res.end(html); }
164
- else { res.writeHead(404); res.end('not found'); }
165
- }).listen(PORT, () => console.log(`敏捷看板: http://localhost:${PORT}`));
107
+ server.listen(PORT, () => {
108
+ console.log(`敏捷看板: http://localhost:${PORT}`);
109
+ console.log(` 数据目录: ${AGILE}`);
110
+ console.log(` 静态产物: ${BOARD_DIR}`);
166
111
  });