scrum-skills 0.0.0-stage → 0.1.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-meta/SKILL.md +36 -0
- package/.agents/skills/scrum-planning/SKILL.md +55 -0
- package/.agents/skills/scrum-planning/references/schema.md +93 -0
- package/.agents/skills/scrum-report/SKILL.md +34 -0
- package/.agents/skills/scrum-sprint/SKILL.md +44 -0
- package/.agents/skills/scrum-tdd/SKILL.md +68 -0
- package/bin/scrum-skills.mjs +138 -0
- package/package.json +19 -6
- package/templates/AGENTS.md +42 -0
- package/tools/serve.mjs +166 -0
- package/tools/validate.mjs +155 -0
- package/README.md +0 -3
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: scrum-meta
|
|
3
|
+
description: 维护与演进 .agents/skills/ 下的敏捷 skill 体系本身。当用户要求修改 skill 规则、引入外部 skill(如 superpowers)、调整工作流纪律时使用。
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# scrum-meta:skill 体系自身的演进
|
|
7
|
+
|
|
8
|
+
## 何时使用
|
|
9
|
+
|
|
10
|
+
用户要求:改某个 skill 的规则/工作流、从外部 skill 合集(superpowers、openspec 等)吸收能力、新增或删除 skill 时。
|
|
11
|
+
|
|
12
|
+
## 核心纪律(对 skill 文件本身也用敏捷的规矩)
|
|
13
|
+
|
|
14
|
+
1. **先说清动机再动手**:改 skill 前用 1~2 轮对话确认要解决的问题是什么(brainstorming 同款:一次问一个问题)。没有真实痛点不动 skill,禁止为改而改。
|
|
15
|
+
2. **修改必须最小且可追溯**:每个改动能回答「这解决什么问题」;改动后向用户复述改了哪些文件哪些节。
|
|
16
|
+
3. **引入外部 skill 前先做映射判断**:
|
|
17
|
+
- 现有 skill(planning/sprint/tdd/report/meta)已覆盖的能力 → 不引入,说明理由即可
|
|
18
|
+
- 有增量价值且与「数据落库、状态机、TDD」纪律兼容 → 融入对应 skill 的相应小节(不新建文件),标注出处
|
|
19
|
+
- 需要独立工作流的才新建 skill 文件
|
|
20
|
+
4. **skill 不膨胀**:每个 SKILL.md 控制在可一屏读完;超了就往 references/ 拆细节。
|
|
21
|
+
5. **同步索引**:改动影响能力边界时,同步更新 `AGENTS.md` 的 skill 索引行和该 skill 的 frontmatter description。
|
|
22
|
+
|
|
23
|
+
## 工作流
|
|
24
|
+
|
|
25
|
+
1. 读当前各 skill + `AGENTS.md`,确认现状
|
|
26
|
+
2. 与用户确认动机与落点(哪个 skill 哪一节 / 新建)
|
|
27
|
+
3. 实施修改(局部编辑,禁止整文件重写)
|
|
28
|
+
4. 若修改了敏捷数据规范(schema/状态机),运行 `node tools/validate.mjs` 确认未破坏
|
|
29
|
+
5. 复述:改动清单 + 每项对应的动机 + 落索引情况
|
|
30
|
+
|
|
31
|
+
## 反模式(禁止)
|
|
32
|
+
|
|
33
|
+
- 不经确认大改 skill 工作流
|
|
34
|
+
- 照抄外部 skill 全文(只吸收方法,用本项目语言重写,标注出处)
|
|
35
|
+
- 新增 skill 却不更新 AGENTS.md 索引
|
|
36
|
+
- 把 skill 改成与本体系冲突的纪律(如允许对话结论不落库)
|
|
@@ -0,0 +1,55 @@
|
|
|
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 前先走「澄清」流程(见下节),澄清结果写进
|
|
16
|
+
`narrative` 和 `acceptance`,不许留在对话记录里。
|
|
17
|
+
3. **数据是唯一事实来源**。后续实施只读文件不读对话记忆。AI 每次接手必须先读 `agile/` 下相关 JSON,再决定下一步。
|
|
18
|
+
|
|
19
|
+
## 需求澄清(brainstorming,源自 superpowers)
|
|
20
|
+
|
|
21
|
+
在写任何 story 之前执行,把模糊想法聊成清晰需求:
|
|
22
|
+
|
|
23
|
+
1. **一次只问一个问题**,一次抛一堆问题等于没问。优先问影响验收的:这东西做完了怎么算成功?谁在用?边界在哪?
|
|
24
|
+
2. **不替用户做主**:澄清中给出选项和各自的代价,让用户拍板;用户的原话是最高优先级输入。
|
|
25
|
+
3. **至少考虑两条路**:写 story 前在内心比对过至少两种实现/拆解方案,被放弃的那条如果用户可能关心,用一句话带过取舍理由(可写进 narrative 或讨论中)。
|
|
26
|
+
4. **适时收敛**:问题问清了就停,不许无限澄清;宁可拆成两个 story 也不做一个大而全的。
|
|
27
|
+
5. **结论即时落库**:每澄清出一个结论,当场更新到对应 story JSON(`narrative`/`acceptance`/`points`),禁止聊完再统一补。
|
|
28
|
+
|
|
29
|
+
## 工作流
|
|
30
|
+
|
|
31
|
+
1. **定位层级**:判断这是 Epic(多迭代的大目标)还是 Story(一个迭代内可交付的用户价值)。拿不准就先建 Epic,Story 留空壳。
|
|
32
|
+
2. **写 Story**:
|
|
33
|
+
- `narrative` 必须是「作为 X,我想要 Y,以便 Z」三段式
|
|
34
|
+
- `acceptance` 至少 1 条,用 given/when/then 格式,必须可验证(能写测试或能演示)
|
|
35
|
+
- `points` 按斐波那契估(1/2/3/5/8),不确定就标 8 并在下次规划重估
|
|
36
|
+
3. **拆 Task**:每个 story 拆成 test/impl/refactor/chore 型任务:
|
|
37
|
+
- **test 型任务先行**:每条可自动验证的 acceptance 至少对应一个 test 型 task
|
|
38
|
+
- test 与 impl 分开拆,不许一个 task 既写测试又写实现
|
|
39
|
+
- 每个 task 必须有 `journal` 字段指向日志文件(先建空壳不写内容)
|
|
40
|
+
4. **编号**:取当前最大编号 +1,E-001/S-001 格式,不复用已删除编号。
|
|
41
|
+
5. **落库后**:向用户复述 story id 与验收标准清单,运行 `node tools/validate.mjs` 确认通过。
|
|
42
|
+
|
|
43
|
+
## 输出模板(对话中的回复结构)
|
|
44
|
+
|
|
45
|
+
1. 一句话结论(做了什么、为什么)
|
|
46
|
+
2. 新建/修改的文件清单
|
|
47
|
+
3. 验收标准逐条列出请用户确认
|
|
48
|
+
4. 校验结果
|
|
49
|
+
|
|
50
|
+
## 反模式(禁止)
|
|
51
|
+
|
|
52
|
+
- 只在对话里描述需求,不落文件
|
|
53
|
+
- acceptance 写「功能正常」「体验良好」这类不可验证的空话
|
|
54
|
+
- 把 test 和 impl 塞进同一个 task
|
|
55
|
+
- 修改 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,68 @@
|
|
|
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(交工前真实验证,源自 superpowers 的 verification-before-completion)
|
|
41
|
+
|
|
42
|
+
1. 最后一次全量测试通过,journal 末条 `result` 为 `pass`
|
|
43
|
+
2. **不许凭断言关单**:「测试过了应该没问题」不算验证。有可运行入口的(页面/命令/接口)要真正跑起来按 acceptance 逐条核一遍;跑不了的也要向用户说明「验证到了哪一步、哪一步没法验」
|
|
44
|
+
3. task 状态 → `done`,`files` 补齐本次触碰的文件
|
|
45
|
+
4. story 的所有 task 都 done → 提醒用户进入 review(scrum-sprint skill)
|
|
46
|
+
|
|
47
|
+
## 排障纪律(源自 superpowers 的 systematic-debugging)
|
|
48
|
+
|
|
49
|
+
测试意外失败、行为不符预期、bug 排查时:
|
|
50
|
+
|
|
51
|
+
1. **先读代码找根因,禁止试错式盲改**——不许靠猜着改再跑一遍碰运气
|
|
52
|
+
2. 四步走:读相关代码形成假设 → 加观测(日志/断点/最小复现)验证假设 → 确认根因 → 做最小修复
|
|
53
|
+
3. 修 bug 也走 TDD:先写一个复现该 bug 的失败测试(red),修复让它转绿(green)
|
|
54
|
+
4. 根因和假设写进 journal 的 `note`(为什么改这里),修复过程照常落 entry
|
|
55
|
+
|
|
56
|
+
## Journal 纪律
|
|
57
|
+
|
|
58
|
+
- 每个 task 一篇 `agile/journal/<storyId>-<taskId>.json`,**每次状态推进必须当场追加 entry**,不许攒到最后补
|
|
59
|
+
- note 写「为什么」不写「什么」(文件名 git log 里都有)
|
|
60
|
+
- 中断(用户喊停/上下文不够):journal 追加一条 note 说明断点在哪,task 状态留在当前阶段
|
|
61
|
+
|
|
62
|
+
## 硬约束
|
|
63
|
+
|
|
64
|
+
- **没 red 过不许写实现**——task 直接从 todo 跳 green 属违规(校验脚本会查 journal 必须先有 red 后有 green)
|
|
65
|
+
- 测试骗不过状态机:`done` 的 task journal 末条必须 pass
|
|
66
|
+
- 不许为让测试通过而删除/跳过/弱化测试;确需改测试时在 journal 的 note 里说明理由
|
|
67
|
+
- 一次只做一个 task 的一个阶段,禁止批量推进多个 task 状态
|
|
68
|
+
- 交工前没做真实验证不许把 task 置 `done`(跑不起入口的须在 journal 里写明验证边界)
|
|
@@ -0,0 +1,138 @@
|
|
|
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 update [dir] [--dry-run] 更新已初始化项目里的 skill 文件(.agents/、AGENTS.md、tools/validate.mjs)
|
|
9
|
+
* 只刷新框架文件,绝不碰 agile/ 数据;每个被覆盖的文件先备份为 *.bak
|
|
10
|
+
* 内容相同的不动;--dry-run 只报告不写入
|
|
11
|
+
* scrum-skills serve [dir] [port] 启动可视化 server(默认当前目录,端口 4173)
|
|
12
|
+
* scrum-skills validate [dir] 校验敏捷数据(默认当前目录)
|
|
13
|
+
*/
|
|
14
|
+
import { cpSync, mkdirSync, existsSync, writeFileSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
15
|
+
import { join, resolve, dirname } from 'node:path';
|
|
16
|
+
import { spawnSync } from 'node:child_process';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
|
|
19
|
+
const PKG_ROOT = fileURLToPath(new URL('..', import.meta.url));
|
|
20
|
+
const HELP = `用法:
|
|
21
|
+
scrum-skills init [dir] [--force] 初始化敏捷开发文件到目标文件夹(默认当前目录)
|
|
22
|
+
scrum-skills update [dir] [--dry-run] 更新已初始化项目的 skill 框架文件(不碰 agile/ 数据)
|
|
23
|
+
scrum-skills serve [dir] [port] 启动可视化 server(默认当前目录,端口 4173)
|
|
24
|
+
scrum-skills validate [dir] 校验敏捷数据(默认当前目录)
|
|
25
|
+
scrum-skills help 显示本帮助`;
|
|
26
|
+
|
|
27
|
+
const [cmd, ...args] = process.argv.slice(2);
|
|
28
|
+
const fail = msg => { console.error(`✘ ${msg}`); process.exit(1); };
|
|
29
|
+
|
|
30
|
+
function init() {
|
|
31
|
+
const force = args.includes('--force');
|
|
32
|
+
const dir = resolve(args.find(a => !a.startsWith('--')) || process.cwd());
|
|
33
|
+
if (!existsSync(dir)) fail(`目录不存在: ${dir}`);
|
|
34
|
+
|
|
35
|
+
const agentsMd = join(dir, 'AGENTS.md');
|
|
36
|
+
if (existsSync(agentsMd) && !force)
|
|
37
|
+
fail(`目标目录已有 AGENTS.md。确认覆盖请加 --force(旧文件会备份为 AGENTS.md.bak)`);
|
|
38
|
+
const backed = existsSync(agentsMd);
|
|
39
|
+
if (backed) cpSync(agentsMd, `${agentsMd}.bak`);
|
|
40
|
+
|
|
41
|
+
cpSync(join(PKG_ROOT, '.agents'), join(dir, '.agents'), { recursive: true, force: true });
|
|
42
|
+
writeFileSync(agentsMd, readFileSync(join(PKG_ROOT, 'templates/AGENTS.md'), 'utf8'));
|
|
43
|
+
|
|
44
|
+
mkdirSync(join(dir, 'tools'), { recursive: true });
|
|
45
|
+
cpSync(join(PKG_ROOT, 'tools/validate.mjs'), join(dir, 'tools/validate.mjs'));
|
|
46
|
+
|
|
47
|
+
for (const sub of ['backlog/epics', 'backlog/stories', 'sprints', 'journal'])
|
|
48
|
+
mkdirSync(join(dir, 'agile', sub), { recursive: true });
|
|
49
|
+
|
|
50
|
+
console.log(`✔ 已初始化到 ${dir}
|
|
51
|
+
- .agents/skills/ 四个敏捷 skill(planning / sprint / tdd / report)
|
|
52
|
+
- AGENTS.md agent 入口纪律文件${backed ? '(旧文件已备份为 AGENTS.md.bak)' : ''}
|
|
53
|
+
- tools/validate.mjs 数据校验脚本
|
|
54
|
+
- agile/ 数据目录骨架(backlog / sprints / journal)
|
|
55
|
+
|
|
56
|
+
下一步:
|
|
57
|
+
1. 在目标项目里让 AI 读 AGENTS.md,用 scrum-planning 建第一个 story
|
|
58
|
+
2. 每次改动后运行: node tools/validate.mjs
|
|
59
|
+
3. 看板/燃尽图: 在目标项目目录下运行 scrum-skills serve`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function walkFiles(dir, base = '') {
|
|
63
|
+
const out = [];
|
|
64
|
+
for (const name of readdirSync(dir)) {
|
|
65
|
+
const abs = join(dir, name);
|
|
66
|
+
const rel = base ? `${base}/${name}` : name;
|
|
67
|
+
if (statSync(abs).isDirectory()) out.push(...walkFiles(abs, rel));
|
|
68
|
+
else out.push({ abs, rel });
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 收集要更新的框架文件:相对路径 → 源绝对路径 */
|
|
74
|
+
function frameworkFiles() {
|
|
75
|
+
const out = [];
|
|
76
|
+
for (const f of walkFiles(join(PKG_ROOT, '.agents')))
|
|
77
|
+
out.push({ rel: `.agents/${f.rel}`, src: f.abs });
|
|
78
|
+
out.push({ rel: 'AGENTS.md', src: join(PKG_ROOT, 'templates/AGENTS.md') });
|
|
79
|
+
out.push({ rel: 'tools/validate.mjs', src: join(PKG_ROOT, 'tools/validate.mjs') });
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function update() {
|
|
84
|
+
const dryRun = args.includes('--dry-run');
|
|
85
|
+
const dir = resolve(args.find(a => !a.startsWith('--')) || process.cwd());
|
|
86
|
+
if (!existsSync(dir)) fail(`目录不存在: ${dir}`);
|
|
87
|
+
if (!existsSync(join(dir, '.agents')) && !existsSync(join(dir, 'AGENTS.md')))
|
|
88
|
+
fail(`${dir} 未初始化(找不到 .agents/ 与 AGENTS.md)。请先运行: scrum-skills init`);
|
|
89
|
+
|
|
90
|
+
const added = [], changed = [], same = [];
|
|
91
|
+
for (const { rel, src } of frameworkFiles()) {
|
|
92
|
+
const dest = join(dir, rel);
|
|
93
|
+
const srcText = readFileSync(src, 'utf8');
|
|
94
|
+
if (!existsSync(dest)) { added.push(rel); if (!dryRun) {
|
|
95
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
96
|
+
writeFileSync(dest, srcText);
|
|
97
|
+
} continue; }
|
|
98
|
+
if (readFileSync(dest, 'utf8') === srcText) { same.push(rel); continue; }
|
|
99
|
+
changed.push(rel);
|
|
100
|
+
if (!dryRun) {
|
|
101
|
+
cpSync(dest, `${dest}.bak`); // 覆盖前备份,本地自定义不丢
|
|
102
|
+
writeFileSync(dest, srcText);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const verb = dryRun ? '(dry-run,未写入)' : '';
|
|
107
|
+
console.log(`✔ skill 框架文件更新完成${verb} — ${dir}
|
|
108
|
+
- 新增 ${added.length} 个${added.length ? `: ${added.join(', ')}` : ''}
|
|
109
|
+
- 更新 ${changed.length} 个(旧文件备份为 *.bak)${changed.length ? `: ${changed.join(', ')}` : ''}
|
|
110
|
+
- 未变 ${same.length} 个
|
|
111
|
+
- agile/ 数据目录未做任何改动
|
|
112
|
+
|
|
113
|
+
${changed.length ? '提示: 更新会覆盖目标项目里对框架文件的本地修改(已备份 *.bak)。若目标项目自定义过 skill,请 diff 对比后手动合并。' : '已是最新,无需操作。'}`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function runTool(script, needPort) {
|
|
117
|
+
const positional = args.filter(a => !a.startsWith('--'));
|
|
118
|
+
const dir = resolve(positional[0] || process.cwd());
|
|
119
|
+
if (!existsSync(dir)) fail(`目录不存在: ${dir}`);
|
|
120
|
+
const port = needPort ? (positional[1] || '4173') : null;
|
|
121
|
+
const r = spawnSync(process.execPath,
|
|
122
|
+
[join(PKG_ROOT, 'tools', script), dir, ...(port ? [port] : [])],
|
|
123
|
+
{ stdio: 'inherit' });
|
|
124
|
+
process.exit(r.status ?? 1);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
switch (cmd) {
|
|
128
|
+
case 'init': init(); break;
|
|
129
|
+
case 'update': update(); break;
|
|
130
|
+
case 'serve': runTool('serve.mjs', true); break;
|
|
131
|
+
case 'validate': runTool('validate.mjs', false); break;
|
|
132
|
+
case 'help': case '-h': case '--help': case undefined:
|
|
133
|
+
console.log(HELP); break;
|
|
134
|
+
default:
|
|
135
|
+
console.error(`未知命令: ${cmd}\n`);
|
|
136
|
+
console.log(HELP);
|
|
137
|
+
process.exit(1);
|
|
138
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "scrum-skills",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "scrum-skills",
|
|
3
|
+
"version": "0.1.1",
|
|
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 纪律) |
|
package/tools/serve.mjs
ADDED
|
@@ -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