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.
- package/.agents/skills/scrum-planning/SKILL.md +10 -4
- package/.agents/skills/scrum-planning/references/schema.md +141 -60
- package/.agents/skills/scrum-report/SKILL.md +8 -4
- package/.agents/skills/scrum-sprint/SKILL.md +8 -3
- package/.agents/skills/scrum-tdd/SKILL.md +3 -3
- package/LICENSE +21 -0
- package/README.md +132 -0
- package/assets/board/200.html +1 -0
- package/assets/board/404.html +1 -0
- package/assets/board/_nuxt/4lorSwrA.js +1 -0
- package/assets/board/_nuxt/7WA2NiBu.js +1 -0
- package/assets/board/_nuxt/BMkghBSN.js +6 -0
- package/assets/board/_nuxt/BO7jzDkR.js +1 -0
- package/assets/board/_nuxt/BZ3zxHIj.js +1 -0
- package/assets/board/_nuxt/BhF-44v-.js +13 -0
- package/assets/board/_nuxt/Brv_Bdja.js +3 -0
- package/assets/board/_nuxt/C3UUYAQd.js +1 -0
- package/assets/board/_nuxt/CJAcsUQO.js +3 -0
- package/assets/board/_nuxt/CNsYxQaQ.js +1 -0
- package/assets/board/_nuxt/CRaaiMq0.js +1 -0
- package/assets/board/_nuxt/CW444i7s.js +1 -0
- package/assets/board/_nuxt/CfONUjDo.js +1 -0
- package/assets/board/_nuxt/DK3Fl9T5.js +1 -0
- package/assets/board/_nuxt/DKNZ_ohS.js +1 -0
- package/assets/board/_nuxt/DuWpPgcw.js +3 -0
- package/assets/board/_nuxt/LjrS__tx.js +1 -0
- package/assets/board/_nuxt/_id_.DhbZaLbc.css +1 -0
- package/assets/board/_nuxt/builds/latest.json +1 -0
- package/assets/board/_nuxt/builds/meta/2c1d27b2-e682-4213-b9a1-85509dcba982.json +1 -0
- package/assets/board/_nuxt/burndown.BENzf335.css +1 -0
- package/assets/board/_nuxt/entry.CRMqPpqu.css +2 -0
- package/assets/board/_nuxt/epics.BeC881O2.css +1 -0
- package/assets/board/_nuxt/error-404.BFmXOnCT.css +1 -0
- package/assets/board/_nuxt/error-500.BZ-DdjQ6.css +1 -0
- package/assets/board/_nuxt/gantt.B1Ztrpq6.css +1 -0
- package/assets/board/_nuxt/laFvd4Y3.js +16 -0
- package/assets/board/_nuxt/milestones.B2QTaR0Y.css +1 -0
- package/assets/board/_nuxt/pages.DuMz_V7L.css +1 -0
- package/assets/board/burndown/index.html +1 -0
- package/assets/board/epics/index.html +1 -0
- package/assets/board/gantt/index.html +1 -0
- package/assets/board/index.html +1 -0
- package/assets/board/milestones/index.html +1 -0
- package/bin/scrum-skills.mjs +17 -5
- package/docs/board.png +0 -0
- package/package.json +24 -3
- package/templates/AGENTS.md +25 -11
- package/tools/serve.mjs +88 -143
- package/tools/validate.mjs +319 -109
package/tools/serve.mjs
CHANGED
|
@@ -1,166 +1,111 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* 敏捷看板 server(零依赖,node >= 18)
|
|
4
4
|
* 用法: node tools/serve.mjs [目标目录] [port] 目录默认 cwd,端口默认 4173
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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 {
|
|
9
|
-
import { join,
|
|
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)
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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
|
-
|
|
92
|
-
const
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
108
|
-
|
|
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
|
-
|
|
131
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
console.
|
|
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
|
});
|