scrum-skills 0.0.0-stage → 0.1.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.
@@ -0,0 +1,44 @@
1
+ ---
2
+ name: scrum-planning
3
+ description: 将用户与 AI 的需求对话约束为结构化敏捷数据(Epic/Story/Task),落库到 agile/ 目录。适用于需求澄清、故事编写、验收标准定义、任务拆解等规划场景。
4
+ ---
5
+
6
+ # scrum-planning:需求对话 → 结构化敏捷数据
7
+
8
+ ## 何时使用
9
+
10
+ 用户提出新需求、新功能、新改进,或要求澄清/拆解/评审已有需求时使用本 skill。
11
+
12
+ ## 核心纪律
13
+
14
+ 1. **禁止只聊天**。凡是确认要做的事,必须落成 `agile/backlog/` 下的 JSON 文件(schema 见 `references/schema.md`)。对话中的口头结论不算数,文件才算数。
15
+ 2. **禁止凭空拆解**。写 story 前先用 1~3 轮对话澄清:角色、价值、边界、验收标准。澄清结果写进 `narrative` 和 `acceptance`,不许留在对话记录里。
16
+ 3. **数据是唯一事实来源**。后续实施只读文件不读对话记忆。AI 每次接手必须先读 `agile/` 下相关 JSON,再决定下一步。
17
+
18
+ ## 工作流
19
+
20
+ 1. **定位层级**:判断这是 Epic(多迭代的大目标)还是 Story(一个迭代内可交付的用户价值)。拿不准就先建 Epic,Story 留空壳。
21
+ 2. **写 Story**:
22
+ - `narrative` 必须是「作为 X,我想要 Y,以便 Z」三段式
23
+ - `acceptance` 至少 1 条,用 given/when/then 格式,必须可验证(能写测试或能演示)
24
+ - `points` 按斐波那契估(1/2/3/5/8),不确定就标 8 并在下次规划重估
25
+ 3. **拆 Task**:每个 story 拆成 test/impl/refactor/chore 型任务:
26
+ - **test 型任务先行**:每条可自动验证的 acceptance 至少对应一个 test 型 task
27
+ - test 与 impl 分开拆,不许一个 task 既写测试又写实现
28
+ - 每个 task 必须有 `journal` 字段指向日志文件(先建空壳不写内容)
29
+ 4. **编号**:取当前最大编号 +1,E-001/S-001 格式,不复用已删除编号。
30
+ 5. **落库后**:向用户复述 story id 与验收标准清单,运行 `node tools/validate.mjs` 确认通过。
31
+
32
+ ## 输出模板(对话中的回复结构)
33
+
34
+ 1. 一句话结论(做了什么、为什么)
35
+ 2. 新建/修改的文件清单
36
+ 3. 验收标准逐条列出请用户确认
37
+ 4. 校验结果
38
+
39
+ ## 反模式(禁止)
40
+
41
+ - 只在对话里描述需求,不落文件
42
+ - acceptance 写「功能正常」「体验良好」这类不可验证的空话
43
+ - 把 test 和 impl 塞进同一个 task
44
+ - 修改 story 时不更新 acceptance 却改了行为(验收标准必须与实现同步演进)
@@ -0,0 +1,93 @@
1
+ # 敏捷数据 Schema 规范(唯一事实来源)
2
+
3
+ 所有文件为 UTF-8 JSON,2 空格缩进,末尾换行。禁止出现 schema 未定义的字段。
4
+ 路径与命名固定:`agile/backlog/epics/E-<3位>.json`、`agile/backlog/stories/S-<3位>.json`、`agile/sprints/<id>.json`、`agile/journal/<storyId>-<taskId>.json`。
5
+
6
+ ## Epic(敏捷史诗)
7
+
8
+ ```json
9
+ {
10
+ "id": "E-001",
11
+ "title": "用户认证",
12
+ "goal": "一句话说明这个史诗完成后用户获得了什么",
13
+ "status": "draft | active | done | cancelled",
14
+ "stories": ["S-001"],
15
+ "created": "2026-10-25"
16
+ }
17
+ ```
18
+
19
+ ## Story(用户故事)
20
+
21
+ ```json
22
+ {
23
+ "id": "S-001",
24
+ "epicId": "E-001",
25
+ "title": "邮箱密码登录",
26
+ "narrative": "作为<角色>,我想要<能力>,以便<价值>",
27
+ "acceptance": [
28
+ { "given": "已注册用户", "when": "输入正确邮箱密码提交", "then": "建立会话并跳转首页" }
29
+ ],
30
+ "tasks": [
31
+ {
32
+ "id": "S-001-T1",
33
+ "title": "为登录接口写失败测试",
34
+ "type": "test | impl | refactor | chore",
35
+ "status": "todo | red | green | refactored | done",
36
+ "files": [],
37
+ "journal": "agile/journal/S-001-T1.json"
38
+ }
39
+ ],
40
+ "points": 3,
41
+ "status": "draft | ready | in-progress | review | done",
42
+ "sprintId": null,
43
+ "created": "2026-10-25"
44
+ }
45
+ ```
46
+
47
+ ## Sprint(迭代)
48
+
49
+ ```json
50
+ {
51
+ "id": "P01-S1",
52
+ "name": "第 1 个迭代",
53
+ "goal": "本迭代要交付什么",
54
+ "start": "2026-10-26",
55
+ "end": "2026-11-08",
56
+ "stories": ["S-001"],
57
+ "capacity": 20
58
+ }
59
+ ```
60
+
61
+ ## Journal(TDD 实施日志,每 task 一篇)
62
+
63
+ ```json
64
+ {
65
+ "taskId": "S-001-T1",
66
+ "storyId": "S-001",
67
+ "entries": [
68
+ {
69
+ "phase": "red | green | refactor",
70
+ "date": "2026-10-26",
71
+ "note": "这一步做了什么、为什么",
72
+ "files": ["src/auth/login.ts"],
73
+ "testCommand": "pnpm test auth",
74
+ "result": "fail: 1 failed, 0 passed | pass: 3 passed"
75
+ }
76
+ ]
77
+ }
78
+ ```
79
+
80
+ ## 状态机(校验脚本强制)
81
+
82
+ - story: `draft → ready → in-progress → review → done`(可回退,不可跳级;`cancelled` 例外)
83
+ - task: `todo → red → green → refactored → done`(只许前进;test 型 task 从 `todo` 直达 `green` 后视情况跳 refactor)
84
+ - epic: 无强制流转,但 `done` 要求其下所有 story 均 `done/cancelled`
85
+
86
+ ## 硬约束(校验脚本强制)
87
+
88
+ 1. 所有 ID 引用必须存在且指向正确类型(epicId / sprintId / stories / journal)
89
+ 2. task 状态为 `green/refactored/done` 时,journal 必须存在且含至少一条 `phase: "red"` 的失败记录和一条 `phase: "green"` 的通过记录
90
+ 3. task 状态为 `done` 时,journal 最后一条 entry 的 `result` 必须是 `pass`
91
+ 4. story 状态为 `done` 时,其所有 task 必须 `done`
92
+ 5. story 进入 sprint(sprintId 非空)前必须为 `ready`(acceptance 非空且 ≥1 条)
93
+ 6. ID 全局唯一且不可复用;文件名与内部 id 一致
@@ -0,0 +1,34 @@
1
+ ---
2
+ name: scrum-report
3
+ description: 从 agile/ 结构化数据生成可视化展示(看板、燃尽图、追溯链)。适用于查看进度、回顾、汇报、核对数据完整性等场景。
4
+ ---
5
+
6
+ # scrum-report:数据可视化
7
+
8
+ ## 何时使用
9
+
10
+ 用户想看进度、看板、燃尽图、某个 story 的实施追溯链,或要求核对数据时使用。
11
+
12
+ ## 工具
13
+
14
+ 启动本地 server(由 scrum-skills CLI 提供,零依赖,node ≥ 18):
15
+
16
+ ```bash
17
+ npx scrum-skills serve # 默认当前目录 http://localhost:4173
18
+ npx scrum-skills serve . 8080 # 指定目录与端口
19
+ ```
20
+
21
+ server 读取 `agile/` 全量 JSON,纯内存聚合,不写文件。改了数据刷新页面即生效。
22
+
23
+ ## 页面
24
+
25
+ - `/` 看板:按 story 状态分列(draft/ready/in-progress/review/done),卡片显示 id、标题、点数、task 进度条
26
+ - `/burndown` 燃尽图:当前活跃 sprint 的剩余点数曲线(无活跃 sprint 时提示)
27
+ - `/story/S-001` 追溯链:acceptance → task → journal 每阶段(red/green/refactor)的完整记录
28
+
29
+ ## 纪律
30
+
31
+ 1. 展示前先跑 `node tools/validate.mjs`,**校验不过的数据不许出报表**——先修数据
32
+ 2. 报表只读 JSON,不许为展示改数据;发现数据缺漏时向用户报告并用 planning/sprint/tdd skill 补
33
+ 3. 向用户汇报进度时引用具体 id(S-001、S-001-T2),不许说「差不多了」「快完成了」这类没有数据支撑的口径
34
+ 4. 燃尽图数据来自 journal 的 date 字段;某 task 没写 journal 导致曲线缺点的,如实显示缺口并指出责任 story
@@ -0,0 +1,44 @@
1
+ ---
2
+ name: scrum-sprint
3
+ description: 管理 Sprint 迭代周期与 Story 状态流转,落库到 agile/sprints/。适用于排期、组建迭代、状态更新、每日进度同步等场景。
4
+ ---
5
+
6
+ # scrum-sprint:迭代排期与状态流转
7
+
8
+ ## 何时使用
9
+
10
+ 用户要求排期、组建/启动/收尾一个迭代、更新任务进度、回顾时使用本 skill。
11
+
12
+ ## 前置
13
+
14
+ 先读 `agile/backlog/stories/` 全量与当前活跃 sprint,掌握真实状态。**状态以文件为准,对话记忆一律作废。**
15
+
16
+ ## 工作流
17
+
18
+ ### 组建 Sprint
19
+
20
+ 1. 从 backlog 中挑 `ready` 状态的 story(不满足 ready 的先回 scrum-planning 补验收标准)
21
+ 2. 总点数不超过 `capacity`(默认 20,可和用户商量)
22
+ 3. 写 `agile/sprints/<id>.json`(id 格式 `P<迭代序号>-S<期号>` 如 `P01-S1`),story 的 `sprintId` 同步更新
23
+ 4. 被选中的 story 状态改为 `in-progress`(或 sprint 启动时改)
24
+
25
+ ### 状态流转(严格按状态机,见 planning skill 的 schema.md)
26
+
27
+ - story: `draft → ready → in-progress → review → done`
28
+ - task: `todo → red → green → refactored → done`
29
+ - 每次流转必须同步落库并运行 `node tools/validate.mjs`
30
+ - story 到 `review` 的条件:所有 task 为 `done` 且验收标准逐条核对过
31
+ - story 到 `done` 由用户确认,AI 不许自行关闭
32
+
33
+ ### 收尾 / 回顾
34
+
35
+ 1. sprint 到期:未完成 story 回 backlog(`sprintId` 置 null,状态回 `ready`),完成项保持 `done`
36
+ 2. 生成回顾小结写入对话(做了什么/没做什么/为什么),不落文件除非用户要求
37
+ 3. 建议用户运行 `node tools/serve.mjs` 查看燃尽图核对进度
38
+
39
+ ## 硬约束
40
+
41
+ - 一次只能有一个活跃 sprint(`start` 已过且 `end` 未到的最多一个)
42
+ - 不许把 `draft` 状态的 story 排进 sprint(校验脚本会报错)
43
+ - task 状态只能前进不能回退;发现做错了就新建 chore task 处理
44
+ - 每次修改后必须校验,校验失败时先修复数据再继续对话
@@ -0,0 +1,57 @@
1
+ ---
2
+ name: scrum-tdd
3
+ description: 以 red-green-refactor TDD 循环驱动任务实施,并把每一步过程落库到 agile/journal/。适用于领取任务、写代码、跑测试、重构等实施场景。
4
+ ---
5
+
6
+ # scrum-tdd:TDD 驱动实施 + 过程落库
7
+
8
+ ## 何时使用
9
+
10
+ 领取 task 开始写代码时使用本 skill。**没有 task id 不开工**——先回 scrum-planning 建故事拆任务。
11
+
12
+ ## 前置
13
+
14
+ 1. 读目标 story 的 JSON(验收标准 + task 列表)
15
+ 2. 找到第一个非 `done` 的 task,确认其 `type`
16
+ 3. 读该 task 的 journal(若有历史条目,从上次断点继续)
17
+
18
+ ## Red-Green-Refactor 循环(每轮三阶段,顺序铁律)
19
+
20
+ ### 🔴 Red(先写失败测试)
21
+
22
+ 1. 按 acceptance 写一条会失败的测试(test 型 task 的本职;impl 型 task 先确认对应 test task 已 red)
23
+ 2. 跑测试,**确认它因正确的原因失败**(不是语法错、不是环境错)
24
+ 3. journal 追加 entry:`phase:"red"`、note 写测试意图、`result` 记 `fail: <原因>`
25
+ 4. task 状态 → `red`
26
+
27
+ ### 🟢 Green(最小实现让测试通过)
28
+
29
+ 1. 写**刚好**让测试通过的实现,不许顺手加没测试覆盖的功能
30
+ 2. 跑测试直到通过
31
+ 3. journal 追加 `phase:"green"` entry,`result` 记 `pass: <统计>`
32
+ 4. task 状态 → `green`
33
+
34
+ ### 🔵 Refactor(在绿灯保护下清理)
35
+
36
+ 1. 测试全绿的前提下消除重复、命名、结构问题;每步小改、改完立即跑测试
37
+ 2. 有实质重构才写 journal entry(`phase:"refactor"`),零碎小改可并入 green entry
38
+ 3. task 状态 → `refactored`(无实质重构可直接 `green → done`)
39
+
40
+ ### ✅ Done
41
+
42
+ 1. 最后一次全量测试通过,journal 末条 `result` 为 `pass`
43
+ 2. task 状态 → `done`,`files` 补齐本次触碰的文件
44
+ 3. story 的所有 task 都 done → 提醒用户进入 review(scrum-sprint skill)
45
+
46
+ ## Journal 纪律
47
+
48
+ - 每个 task 一篇 `agile/journal/<storyId>-<taskId>.json`,**每次状态推进必须当场追加 entry**,不许攒到最后补
49
+ - note 写「为什么」不写「什么」(文件名 git log 里都有)
50
+ - 中断(用户喊停/上下文不够):journal 追加一条 note 说明断点在哪,task 状态留在当前阶段
51
+
52
+ ## 硬约束
53
+
54
+ - **没 red 过不许写实现**——task 直接从 todo 跳 green 属违规(校验脚本会查 journal 必须先有 red 后有 green)
55
+ - 测试骗不过状态机:`done` 的 task journal 末条必须 pass
56
+ - 不许为让测试通过而删除/跳过/弱化测试;确需改测试时在 journal 的 note 里说明理由
57
+ - 一次只做一个 task 的一个阶段,禁止批量推进多个 task 状态
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * scrum-skills CLI(零依赖,node >= 18)
4
+ * 命令:
5
+ * scrum-skills init [dir] [--force] 初始化敏捷开发文件到目标文件夹(默认当前目录)
6
+ * 拷贝 .agents/skills/、AGENTS.md、tools/validate.mjs,建 agile/ 目录骨架
7
+ * 已存在 AGENTS.md 时拒绝,--force 覆盖(备份为 AGENTS.md.bak)
8
+ * scrum-skills serve [dir] [port] 启动可视化 server(默认当前目录,端口 4173)
9
+ * scrum-skills validate [dir] 校验敏捷数据(默认当前目录)
10
+ */
11
+ import { cpSync, mkdirSync, existsSync, writeFileSync, readFileSync } from 'node:fs';
12
+ import { join, resolve } from 'node:path';
13
+ import { spawnSync } from 'node:child_process';
14
+ import { fileURLToPath } from 'node:url';
15
+
16
+ const PKG_ROOT = fileURLToPath(new URL('..', import.meta.url));
17
+ const HELP = `用法:
18
+ scrum-skills init [dir] [--force] 初始化敏捷开发文件到目标文件夹(默认当前目录)
19
+ scrum-skills serve [dir] [port] 启动可视化 server(默认当前目录,端口 4173)
20
+ scrum-skills validate [dir] 校验敏捷数据(默认当前目录)
21
+ scrum-skills help 显示本帮助`;
22
+
23
+ const [cmd, ...args] = process.argv.slice(2);
24
+ const fail = msg => { console.error(`✘ ${msg}`); process.exit(1); };
25
+
26
+ function init() {
27
+ const force = args.includes('--force');
28
+ const dir = resolve(args.find(a => !a.startsWith('--')) || process.cwd());
29
+ if (!existsSync(dir)) fail(`目录不存在: ${dir}`);
30
+
31
+ const agentsMd = join(dir, 'AGENTS.md');
32
+ if (existsSync(agentsMd) && !force)
33
+ fail(`目标目录已有 AGENTS.md。确认覆盖请加 --force(旧文件会备份为 AGENTS.md.bak)`);
34
+ const backed = existsSync(agentsMd);
35
+ if (backed) cpSync(agentsMd, `${agentsMd}.bak`);
36
+
37
+ cpSync(join(PKG_ROOT, '.agents'), join(dir, '.agents'), { recursive: true, force: true });
38
+ writeFileSync(agentsMd, readFileSync(join(PKG_ROOT, 'templates/AGENTS.md'), 'utf8'));
39
+
40
+ mkdirSync(join(dir, 'tools'), { recursive: true });
41
+ cpSync(join(PKG_ROOT, 'tools/validate.mjs'), join(dir, 'tools/validate.mjs'));
42
+
43
+ for (const sub of ['backlog/epics', 'backlog/stories', 'sprints', 'journal'])
44
+ mkdirSync(join(dir, 'agile', sub), { recursive: true });
45
+
46
+ console.log(`✔ 已初始化到 ${dir}
47
+ - .agents/skills/ 四个敏捷 skill(planning / sprint / tdd / report)
48
+ - AGENTS.md agent 入口纪律文件${backed ? '(旧文件已备份为 AGENTS.md.bak)' : ''}
49
+ - tools/validate.mjs 数据校验脚本
50
+ - agile/ 数据目录骨架(backlog / sprints / journal)
51
+
52
+ 下一步:
53
+ 1. 在目标项目里让 AI 读 AGENTS.md,用 scrum-planning 建第一个 story
54
+ 2. 每次改动后运行: node tools/validate.mjs
55
+ 3. 看板/燃尽图: 在目标项目目录下运行 scrum-skills serve`);
56
+ }
57
+
58
+ function runTool(script, needPort) {
59
+ const positional = args.filter(a => !a.startsWith('--'));
60
+ const dir = resolve(positional[0] || process.cwd());
61
+ if (!existsSync(dir)) fail(`目录不存在: ${dir}`);
62
+ const port = needPort ? (positional[1] || '4173') : null;
63
+ const r = spawnSync(process.execPath,
64
+ [join(PKG_ROOT, 'tools', script), dir, ...(port ? [port] : [])],
65
+ { stdio: 'inherit' });
66
+ process.exit(r.status ?? 1);
67
+ }
68
+
69
+ switch (cmd) {
70
+ case 'init': init(); break;
71
+ case 'serve': runTool('serve.mjs', true); break;
72
+ case 'validate': runTool('validate.mjs', false); break;
73
+ case 'help': case '-h': case '--help': case undefined:
74
+ console.log(HELP); break;
75
+ default:
76
+ console.error(`未知命令: ${cmd}\n`);
77
+ console.log(HELP);
78
+ process.exit(1);
79
+ }
package/package.json CHANGED
@@ -1,6 +1,19 @@
1
- {
2
- "name": "scrum-skills",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
1
+ {
2
+ "name": "scrum-skills",
3
+ "version": "0.1.0",
4
+ "description": "结构化敏捷开发管理 skills + CLI:AI 对话产出结构化敏捷数据,TDD 驱动实施,数据可视化看板",
5
+ "type": "module",
6
+ "bin": {
7
+ "scrum-skills": "./bin/scrum-skills.mjs"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "tools/",
12
+ "templates/",
13
+ ".agents/"
14
+ ],
15
+ "engines": {
16
+ "node": ">=18"
17
+ },
18
+ "license": "MIT"
19
+ }
@@ -0,0 +1,42 @@
1
+ # AGENTS.md
2
+
3
+ 本项目采用结构化敏捷开发管理。**任何 agent(CodeBuddy / Claude Code / Cursor 等)在本项目内工作前,必须先读本文件并遵守以下纪律。**
4
+
5
+ ## 强制规则
6
+
7
+ 1. **数据是唯一事实来源**。所有需求、任务、进度状态以 `agile/` 目录下的 JSON 文件为准,对话记忆一律不作数。每次接手工作先读 `agile/` 掌握现状,再决定下一步。
8
+ 2. **必须使用敏捷 skills**(位于 `.agents/skills/`),按场景加载,不要全量加载(省上下文):
9
+ - 需求澄清、写故事、拆任务 → **scrum-planning**
10
+ - 排期、组建迭代、状态流转、收尾回顾 → **scrum-sprint**
11
+ - 领取任务写代码(TDD 循环 + 实施日志落库)→ **scrum-tdd**
12
+ - 看板/燃尽图/追溯链可视化 → **scrum-report**
13
+ 3. **对话产出必须落库**。凡是确认要做的事,必须写进 `agile/backlog/`(结构见 `.agents/skills/scrum-planning/references/schema.md`),禁止只停留在对话里。
14
+ 4. **TDD 铁律**:任务实施走 red → green → refactor 循环,没写过失败测试不许写实现;每步过程必须当场写入 `agile/journal/`(不许事后补记)。
15
+ 5. **每次修改敏捷数据后必须运行校验**,校验不过不许继续:
16
+
17
+ ```bash
18
+ node tools/validate.mjs
19
+ ```
20
+
21
+ 6. **可视化**(server 不在本项目内,由 scrum-skills CLI 提供):
22
+
23
+ ```bash
24
+ npx scrum-skills serve # http://localhost:4173 看板 / 燃尽图 / story 追溯链
25
+ ```
26
+
27
+ ## 状态机(摘要)
28
+
29
+ - story: `draft → ready → in-progress → review → done`
30
+ - task: `todo → red → green → refactored → done`(只能前进;task 状态编码了 TDD 阶段)
31
+ - 详细规则与硬约束见 `.agents/skills/scrum-planning/references/schema.md`。
32
+
33
+ ## 目录索引
34
+
35
+ | 路径 | 内容 |
36
+ |---|---|
37
+ | `.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 纪律) |
@@ -0,0 +1,166 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 敏捷数据可视化 server(零依赖,node >= 18)
4
+ * 用法: node tools/serve.mjs [目标目录] [port] 目录默认 cwd,端口默认 4173
5
+ * 页面: / 看板 | /burndown 燃尽图 | /story/:id 追溯链
6
+ * 校验不通过时拒绝出报表,提示先跑 validate
7
+ */
8
+ import { readdirSync, readFileSync, existsSync } from 'node:fs';
9
+ import { join, basename, resolve } from 'node:path';
10
+ import { execFileSync } from 'node:child_process';
11
+
12
+ const PKG_ROOT = new URL('..', import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1');
13
+ const TARGET = resolve(process.argv[2] || process.cwd());
14
+ const PORT = Number(process.argv[3] || 4173);
15
+ const AGILE = join(TARGET, 'agile');
16
+
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;
24
+ }
25
+ function load() {
26
+ 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')),
31
+ };
32
+ }
33
+
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 };
40
+ }
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);
63
+ }
64
+ days.push({ date: ds, remaining: Math.round((total - burned) * 10) / 10 });
65
+ }
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
+ 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
+ }
106
+
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
+ }
129
+
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
+ }
148
+
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}`));
166
+ });
@@ -0,0 +1,155 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 敏捷数据校验脚本(零依赖)
4
+ * 用法: node tools/validate.mjs [目标目录] 默认当前工作目录
5
+ * 校验 <目标>/agile/ 下所有 JSON 的引用完整性、状态机、TDD 纪律。
6
+ * 退出码: 0 通过, 1 有错误。
7
+ */
8
+ import { readdirSync, readFileSync, existsSync } from 'node:fs';
9
+ import { join, basename, resolve } from 'node:path';
10
+
11
+ const TARGET = resolve(process.argv[2] || process.cwd());
12
+ const AGILE = join(TARGET, 'agile');
13
+ const errors = [];
14
+ const warns = [];
15
+
16
+ function readJson(dir) {
17
+ const out = [];
18
+ if (!existsSync(dir)) return out;
19
+ for (const f of readdirSync(dir)) {
20
+ if (!f.endsWith('.json')) continue;
21
+ const p = join(dir, f);
22
+ try {
23
+ out.push({ file: p, data: JSON.parse(readFileSync(p, 'utf8')) });
24
+ } catch (e) {
25
+ errors.push(`${p}: JSON 解析失败 - ${e.message}`);
26
+ }
27
+ }
28
+ return out;
29
+ }
30
+
31
+ const epics = readJson(join(AGILE, 'backlog/epics'));
32
+ const stories = readJson(join(AGILE, 'backlog/stories'));
33
+ const sprints = readJson(join(AGILE, 'sprints'));
34
+ const journals = readJson(join(AGILE, 'journal'));
35
+
36
+ const epicIds = new Set(epics.map(e => e.data.id));
37
+ const storyIds = new Set(stories.map(s => s.data.id));
38
+ const sprintIds = new Set(sprints.map(s => s.data.id));
39
+ const allIds = new Set();
40
+ const journalTaskIds = new Set();
41
+
42
+ // 文件名与内部 id 一致(journal 按 taskId 对应 task,单独去重)
43
+ for (const [items, key] of [[epics, 'id'], [stories, 'id'], [sprints, 'id'], [journals, 'taskId']]) {
44
+ for (const it of items) {
45
+ const id = it.data[key];
46
+ if (!id) { errors.push(`${it.file}: 缺少 ${key}`); continue; }
47
+ const stem = basename(it.file, '.json');
48
+ if (stem !== id) errors.push(`${it.file}: 文件名 ${stem} 与内部 ${key} ${id} 不一致`);
49
+ const bucket = key === 'taskId' ? journalTaskIds : allIds;
50
+ if (bucket.has(id)) errors.push(`${it.file}: ${key} ${id} 重复`);
51
+ bucket.add(id);
52
+ }
53
+ }
54
+ for (const j of journals) {
55
+ const sid = j.data.storyId;
56
+ if (sid && !storyIds.has(sid)) errors.push(`${j.file}: storyId ${sid} 不存在`);
57
+ }
58
+
59
+ const STORY_FLOW = ['draft', 'ready', 'in-progress', 'review', 'done'];
60
+ const TASK_FLOW = ['todo', 'red', 'green', 'refactored', 'done'];
61
+
62
+ // Story 校验
63
+ for (const s of stories) {
64
+ const d = s.file && s.data;
65
+ const { id, epicId, sprintId, status, acceptance, tasks } = d;
66
+ if (epicId && !epicIds.has(epicId)) errors.push(`${s.file}: epicId ${epicId} 不存在`);
67
+ if (sprintId && !sprintIds.has(sprintId)) errors.push(`${s.file}: sprintId ${sprintId} 不存在`);
68
+ if (!STORY_FLOW.includes(status) && status !== 'cancelled') errors.push(`${s.file}: 非法 story 状态 ${status}`);
69
+ if ((status === 'ready' || STORY_FLOW.indexOf(status) > 1) && (!acceptance || acceptance.length === 0))
70
+ errors.push(`${s.file}: 状态 ${status} 但验收标准为空`);
71
+ if (sprintId && (!acceptance || acceptance.length === 0))
72
+ errors.push(`${s.file}: 已进 sprint 但无验收标准(不满足 ready)`);
73
+ if (status === 'done') {
74
+ for (const t of tasks ?? []) {
75
+ if (t.status !== 'done' && t.status !== 'cancelled')
76
+ errors.push(`${s.file}: story 已 done 但 task ${t.id} 状态为 ${t.status}`);
77
+ }
78
+ }
79
+ for (const t of tasks ?? []) {
80
+ if (allIds.has(t.id)) errors.push(`${s.file}: task id ${t.id} 重复`);
81
+ allIds.add(t.id);
82
+ if (!TASK_FLOW.includes(t.status)) errors.push(`${s.file}: task ${t.id} 非法状态 ${t.status}`);
83
+ }
84
+ }
85
+
86
+ // Task → journal TDD 纪律
87
+ const journalByTask = new Map(journals.map(j => [j.data.taskId, j]));
88
+ const realTaskIds = new Set();
89
+ for (const s of stories) for (const t of s.data.tasks ?? []) realTaskIds.add(t.id);
90
+ for (const [tid] of journalByTask) {
91
+ if (!realTaskIds.has(tid)) errors.push(`journal: taskId ${tid} 不指向任何真实 task`);
92
+ }
93
+ for (const s of stories) {
94
+ for (const t of s.data.tasks ?? []) {
95
+ const hasRed = ['red', 'green', 'refactored', 'done'].includes(t.status);
96
+ const hasGreen = ['green', 'refactored', 'done'].includes(t.status);
97
+ const jShell = journalByTask.get(t.id);
98
+ if (!hasRed && t.journal && jShell && (jShell.data.entries ?? []).length > 0)
99
+ warns.push(`${t.id}: 尚在 todo 但 journal 已有实施记录(检查是否提前动了手)`);
100
+ if (!hasRed) continue;
101
+ const j = journalByTask.get(t.id);
102
+ if (!j) { errors.push(`task ${t.id}: 状态 ${t.status} 但 journal 缺失`); continue; }
103
+ const entries = j.data.entries ?? [];
104
+ const firstRedIdx = entries.findIndex(e => e.phase === 'red');
105
+ const firstGreenIdx = entries.findIndex(e => e.phase === 'green');
106
+ if (firstRedIdx < 0) errors.push(`task ${t.id}: journal 没有 red 记录(先写失败测试的铁律被违反)`);
107
+ else if (entries[firstRedIdx].result && !/^fail/i.test(entries[firstRedIdx].result))
108
+ errors.push(`task ${t.id}: 第一条 red 记录的 result 应为 fail,实际 "${entries[firstRedIdx].result}"`);
109
+ if (hasGreen) {
110
+ if (firstGreenIdx < 0) errors.push(`task ${t.id}: 状态已过 red 但 journal 没有 green 记录`);
111
+ else if (entries[firstGreenIdx].result && !/^pass/i.test(entries[firstGreenIdx].result))
112
+ errors.push(`task ${t.id}: 第一条 green 记录的 result 应为 pass`);
113
+ }
114
+ if (t.status === 'done') {
115
+ const last = entries[entries.length - 1];
116
+ if (!last || !last.result || !/^pass/i.test(last.result))
117
+ errors.push(`task ${t.id}: 已 done 但 journal 末条记录不是 pass`);
118
+ }
119
+ }
120
+ }
121
+
122
+ // Epic 校验
123
+ for (const e of epics) {
124
+ for (const sid of e.data.stories ?? []) {
125
+ if (!storyIds.has(sid)) errors.push(`${e.file}: 引用 story ${sid} 不存在`);
126
+ }
127
+ if (e.data.status === 'done') {
128
+ for (const sid of e.data.stories ?? []) {
129
+ const st = stories.find(s => s.data.id === sid);
130
+ if (st && !['done', 'cancelled'].includes(st.data.status))
131
+ errors.push(`${e.file}: epic 已 done 但 story ${sid} 状态为 ${st.data.status}`);
132
+ }
133
+ }
134
+ }
135
+
136
+ // Sprint 校验:引用 + 唯一活跃
137
+ const today = new Date().toISOString().slice(0, 10);
138
+ let active = 0;
139
+ for (const sp of sprints) {
140
+ for (const sid of sp.data.stories ?? []) {
141
+ if (!storyIds.has(sid)) errors.push(`${sp.file}: 引用 story ${sid} 不存在`);
142
+ }
143
+ if (sp.data.start <= today && sp.data.end >= today) active++;
144
+ }
145
+ if (active > 1) errors.push(`同时存在 ${active} 个活跃 sprint,只允许一个`);
146
+
147
+ // 输出
148
+ for (const w of warns) console.warn(` WARN ${w}`);
149
+ if (errors.length === 0) {
150
+ console.log(`✔ 校验通过:${epics.length} epics, ${stories.length} stories, ${sprints.length} sprints, ${journals.length} journals`);
151
+ } else {
152
+ console.error(`✘ 校验失败,共 ${errors.length} 处错误:`);
153
+ for (const e of errors) console.error(` - ${e}`);
154
+ process.exit(1);
155
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.