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
@@ -3,7 +3,7 @@
3
3
  * scrum-skills CLI(零依赖,node >= 18)
4
4
  * 命令:
5
5
  * scrum-skills init [dir] [--force] 初始化敏捷开发文件到目标文件夹(默认当前目录)
6
- * 拷贝 .agents/skills/、AGENTS.md、tools/validate.mjs,建 agile/ 目录骨架
6
+ * 拷贝 .agents/skills/、AGENTS.md、tools/validate.mjs,建 agile/ 三文件骨架
7
7
  * 已存在 AGENTS.md 时拒绝,--force 覆盖(备份为 AGENTS.md.bak)
8
8
  * scrum-skills update [dir] [--dry-run] 更新已初始化项目里的 skill 文件(.agents/、AGENTS.md、tools/validate.mjs)
9
9
  * 只刷新框架文件,绝不碰 agile/ 数据;每个被覆盖的文件先备份为 *.bak
@@ -46,19 +46,31 @@ function init() {
46
46
  mkdirSync(join(dir, 'tools'), { recursive: true });
47
47
  cpSync(join(PKG_ROOT, 'tools/validate.mjs'), join(dir, 'tools/validate.mjs'));
48
48
 
49
- for (const sub of ['backlog/epics', 'backlog/stories', 'sprints', 'journal'])
50
- mkdirSync(join(dir, 'agile', sub), { recursive: true });
49
+ // 数据骨架:schema v2 三文件结构(已有文件不覆盖,避免踩坏真实数据)
50
+ mkdirSync(join(dir, 'agile'), { recursive: true });
51
+ const skeleton = {
52
+ 'backlog.json': { schemaVersion: 3, epics: [], stories: [] },
53
+ 'sprints.json': { schemaVersion: 3, sprints: [], milestones: [] },
54
+ 'journal.json': { schemaVersion: 3, journals: [] },
55
+ };
56
+ const created = [];
57
+ for (const [name, body] of Object.entries(skeleton)) {
58
+ const p = join(dir, 'agile', name);
59
+ if (existsSync(p)) continue;
60
+ writeFileSync(p, JSON.stringify(body, null, 2) + '\n');
61
+ created.push(`agile/${name}`);
62
+ }
51
63
 
52
64
  console.log(`✔ 已初始化到 ${dir}
53
65
  - .agents/skills/ 四个敏捷 skill(planning / sprint / tdd / report)
54
66
  - AGENTS.md agent 入口纪律文件${backed ? '(旧文件已备份为 AGENTS.md.bak)' : ''}
55
67
  - tools/validate.mjs 数据校验脚本
56
- - agile/ 数据目录骨架(backlog / sprints / journal)
68
+ - agile/ 三文件结构${created.length ? `(新建 ${created.join('、')})` : '(已存在,未覆盖)'}
57
69
 
58
70
  下一步:
59
71
  1. 在目标项目里让 AI 读 AGENTS.md,用 scrum-planning 建第一个 story
60
72
  2. 每次改动后运行: node tools/validate.mjs
61
- 3. 看板/燃尽图: 在目标项目目录下运行 scrum-skills serve`);
73
+ 3. 看板: 在目标项目目录下运行 scrum-skills serve,打开 http://localhost:4173`);
62
74
  }
63
75
 
64
76
  function walkFiles(dir, base = '') {
package/docs/board.png ADDED
Binary file
package/package.json CHANGED
@@ -1,16 +1,37 @@
1
1
  {
2
2
  "name": "scrum-skills",
3
- "version": "0.1.2",
4
- "description": "结构化敏捷开发管理 skills + CLI:AI 对话产出结构化敏捷数据,TDD 驱动实施,数据可视化看板",
3
+ "version": "0.2.0",
4
+ "description": "结构化敏捷开发管理 skills + CLI:AI 对话产出结构化敏捷数据,TDD 驱动实施,Nuxt 看板可视化",
5
5
  "type": "module",
6
+ "keywords": [
7
+ "agile",
8
+ "scrum",
9
+ "sprint",
10
+ "tdd",
11
+ "kanban",
12
+ "gantt",
13
+ "burndown",
14
+ "skills",
15
+ "cli"
16
+ ],
6
17
  "bin": {
7
18
  "scrum-skills": "./bin/scrum-skills.mjs"
8
19
  },
20
+ "scripts": {
21
+ "validate": "node tools/validate.mjs",
22
+ "serve": "node tools/serve.mjs",
23
+ "test": "node --test",
24
+ "dev": "pnpm --dir apps/board run dev",
25
+ "build": "node scripts/build-board.mjs",
26
+ "prepublishOnly": "node scripts/build-board.mjs"
27
+ },
9
28
  "files": [
10
29
  "bin/",
11
30
  "tools/",
12
31
  "templates/",
13
- ".agents/"
32
+ ".agents/",
33
+ "assets/",
34
+ "docs/"
14
35
  ],
15
36
  "engines": {
16
37
  "node": ">=18"
@@ -6,22 +6,28 @@
6
6
 
7
7
  1. **数据是唯一事实来源**。所有需求、任务、进度状态以 `agile/` 目录下的 JSON 文件为准,对话记忆一律不作数。每次接手工作先读 `agile/` 掌握现状,再决定下一步。
8
8
  2. **必须使用敏捷 skills**(位于 `.agents/skills/`),按场景加载,不要全量加载(省上下文):
9
- - 需求澄清、写故事、拆任务 → **scrum-planning**
9
+ - 需求澄清(brainstorming 式逐问收敛)、写故事、拆任务 → **scrum-planning**
10
10
  - 排期、组建迭代、状态流转、收尾回顾 → **scrum-sprint**
11
- - 领取任务写代码(TDD 循环 + 实施日志落库)→ **scrum-tdd**
11
+ - 领取任务写代码(TDD 循环 + 实施日志落库;含系统化排障纪律与交工前真实验证)→ **scrum-tdd**
12
12
  - 看板/燃尽图/追溯链可视化 → **scrum-report**
13
- 3. **对话产出必须落库**。凡是确认要做的事,必须写进 `agile/backlog/`(结构见 `.agents/skills/scrum-planning/references/schema.md`),禁止只停留在对话里。
14
- 4. **TDD 铁律**:任务实施走 red → green → refactor 循环,没写过失败测试不许写实现;每步过程必须当场写入 `agile/journal/`(不许事后补记)。
13
+ 3. **对话产出必须落库**。凡是确认要做的事,必须写进 `agile/backlog.json`(结构见 `.agents/skills/scrum-planning/references/schema.md`),禁止只停留在对话里。每条故事必须带 `type` / `priority` / `value`,验收标准必须带 `verifyBy`;依赖写 `dependsOn`,被卡住写 `blocked`,中途插入或拆分出来的写 `scopeChange`——这些不写进 JSON,看板和甘特图就读不到。
14
+ 4. **TDD 铁律**:任务实施走 red → green → refactor 循环,没写过失败测试不许写实现;每步过程必须当场追加到 `agile/journal.json` 里对应 task 的 `entries`(不许事后补记)。
15
15
  5. **每次修改敏捷数据后必须运行校验**,校验不过不许继续:
16
16
 
17
17
  ```bash
18
18
  node tools/validate.mjs
19
19
  ```
20
20
 
21
- 6. **可视化**(server 不在本项目内,由 scrum-skills CLI 提供):
21
+ 动了 `tools/validate.mjs` 本身则要跑回归测试,确认没把既有规则弄回退:
22
22
 
23
23
  ```bash
24
- npx scrum-skills serve # http://localhost:4173 看板 / 燃尽图 / story 追溯链
24
+ pnpm test
25
+ ```
26
+
27
+ 6. **可视化**:
28
+
29
+ ```bash
30
+ node tools/serve.mjs # http://localhost:4173 看板 / 燃尽图 / story 追溯链
25
31
  ```
26
32
 
27
33
  ## 状态机(摘要)
@@ -35,8 +41,16 @@
35
41
  | 路径 | 内容 |
36
42
  |---|---|
37
43
  | `.agents/skills/` | 四个敏捷 skill(planning / sprint / tdd / report) |
38
- | `agile/backlog/epics/` | 史诗 E-*.json |
39
- | `agile/backlog/stories/` | 用户故事 S-*.json(含验收标准与任务) |
40
- | `agile/sprints/` | 迭代 P*-S*.json |
41
- | `agile/journal/` | 每任务一篇 TDD 实施日志 |
42
- | `tools/validate.mjs` | 数据校验(引用完整性 / 状态机 / TDD 纪律) |
44
+ | `agile/backlog.json` | 史诗 epics + 用户故事 stories(story 内联 tasks) |
45
+ | `agile/sprints.json` | 迭代 sprints + 里程碑 milestones(MVP 节点 = `kind: "mvp"`) |
46
+ | `agile/journal.json` | 每个 task 一条 TDD 实施日志(内联 entries) |
47
+ | `tools/validate.mjs` | 数据校验(引用完整性 / 状态机 / TDD 纪律 / 里程碑达成) |
48
+ | `tools/serve.mjs` | 看板 server(`/api/agile` + 托管 Nuxt 静态产物) |
49
+ | `apps/board/` | Nuxt 看板源码(看板 / 甘特图 / 里程碑 / 燃尽图 / 故事追溯) |
50
+ | `assets/board/` | 看板静态产物,由 `serve` 托管;**不进 git**,用 `pnpm board:build` 重建(`npm publish` 的 prepack 会自动跑一次) |
51
+ | `scripts/build-board.mjs` | 构建看板:`apps/board` → `assets/board` |
52
+ | `tests/` | 校验器回归测试(`pnpm test`,18 个用例) |
53
+ | `docs/` | README 配图 |
54
+ | `README.md` | 包说明(npm 页面) |
55
+
56
+ `agile/` 三个文件里目前是一整套演示数据(3 史诗 / 16 故事 / 4 迭代 / 5 里程碑 / 39 任务),正式使用前清空三文件的数组即可。
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
  });