scrum-skills 0.2.3 → 0.2.5

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 (82) hide show
  1. package/.agents/skills/scrum-planning/references/schema.md +3 -2
  2. package/README.md +17 -8
  3. package/assets/board/200.html +1 -1
  4. package/assets/board/404.html +1 -1
  5. package/assets/board/_nuxt/{C9Eg7sGC.js → -8mcJ5uq.js} +1 -1
  6. package/assets/board/_nuxt/{DA0S9GHd.js → 9qnowzZi.js} +1 -1
  7. package/assets/board/_nuxt/{BQSM1Ln0.js → BPTrRhcw.js} +1 -1
  8. package/assets/board/_nuxt/{B7foC6AH.js → BSov4ehT.js} +1 -1
  9. package/assets/board/_nuxt/{D8NAsEfE.js → Bb3Limos.js} +1 -1
  10. package/assets/board/_nuxt/Bi9Axj3V.js +1 -0
  11. package/assets/board/_nuxt/{C9lP5soO.js → BkjUg7GC.js} +1 -1
  12. package/assets/board/_nuxt/Bu400pfa.js +1 -0
  13. package/assets/board/_nuxt/{DiYBussm.js → BuuAjxsV.js} +1 -1
  14. package/assets/board/_nuxt/{DEBhVwtl.js → C9StZL3D.js} +1 -1
  15. package/assets/board/_nuxt/{B5u1XkK4.js → CBhhj6QT.js} +1 -1
  16. package/assets/board/_nuxt/{ADHYAojV.js → CD0uJuyA.js} +1 -1
  17. package/assets/board/_nuxt/{C3ssq3NT.js → CHZ_IHOa.js} +1 -1
  18. package/assets/board/_nuxt/{DVC07Kf9.js → CUnX_QKi.js} +1 -1
  19. package/assets/board/_nuxt/{BJ0EVa07.js → CdvimHoq.js} +1 -1
  20. package/assets/board/_nuxt/{DhkiugsE.js → ChJuu-Ky.js} +1 -1
  21. package/assets/board/_nuxt/{DwNOEVN_.js → CjRYYbsC.js} +1 -1
  22. package/assets/board/_nuxt/Co0ISufR.js +95 -0
  23. package/assets/board/_nuxt/CuJUHLl8.js +1 -0
  24. package/assets/board/_nuxt/{BKgQBiDB.js → CvFE1CeN.js} +1 -1
  25. package/assets/board/_nuxt/{CAJYxF-h.js → CyAQx5wv.js} +1 -1
  26. package/assets/board/_nuxt/{CviJuCa4.js → D3YCAmfu.js} +1 -1
  27. package/assets/board/_nuxt/{joIhBaO-.js → D8Bpf7sE.js} +1 -1
  28. package/assets/board/_nuxt/DDA06Sqv.js +1 -0
  29. package/assets/board/_nuxt/DFuirznt.js +1 -0
  30. package/assets/board/_nuxt/{4FVXNzN-.js → DG3IySrP.js} +1 -1
  31. package/assets/board/_nuxt/{BXQZAVYz.js → DLkAKotw.js} +1 -1
  32. package/assets/board/_nuxt/DNMF9lr0.js +1 -0
  33. package/assets/board/_nuxt/{DQRn5Z3H.js → DaqxYsbV.js} +1 -1
  34. package/assets/board/_nuxt/{jCMbKsJ3.js → DhhR1iR4.js} +1 -1
  35. package/assets/board/_nuxt/{CL3OrPP7.js → DmKZi-Wo.js} +1 -1
  36. package/assets/board/_nuxt/{DcWIG384.js → DsNkyZPc.js} +1 -1
  37. package/assets/board/_nuxt/{BCXs9rBJ.js → DwcwzwSx.js} +1 -1
  38. package/assets/board/_nuxt/Dzd1U0TD.js +1 -0
  39. package/assets/board/_nuxt/{BuVRe79A.js → HrhQecsY.js} +1 -1
  40. package/assets/board/_nuxt/{Db4IStii.js → KhBLkIKl.js} +1 -1
  41. package/assets/board/_nuxt/{bXQUY80V.js → MTucdeiW.js} +1 -1
  42. package/assets/board/_nuxt/{CZPwmwA5.js → RJtjdZeG.js} +1 -1
  43. package/assets/board/_nuxt/Vo4wMbnS.js +1 -0
  44. package/assets/board/_nuxt/Y5b2rXjB.js +1 -0
  45. package/assets/board/_nuxt/builds/latest.json +1 -1
  46. package/assets/board/_nuxt/builds/meta/a2ba436a-230e-43f9-884e-0a5d37f5ba22.json +1 -0
  47. package/assets/board/_nuxt/{CPfeOlrH.js → d-5IhxZm.js} +1 -1
  48. package/assets/board/_nuxt/entry.CTKz4ouw.css +2 -0
  49. package/assets/board/_nuxt/gRdnZpJw.js +1 -0
  50. package/assets/board/_nuxt/{-_kK9OzO.js → l05SqxhY.js} +1 -1
  51. package/assets/board/_nuxt/milestones.z-UuT44X.css +1 -0
  52. package/assets/board/_nuxt/{GzmVZqnA.js → oIa7syc0.js} +1 -1
  53. package/assets/board/_nuxt/pages.YYGvroeE.css +1 -0
  54. package/assets/board/_nuxt/{BannxgAO.js → qDn8FnYE.js} +1 -1
  55. package/assets/board/_nuxt/uGmbQYdY.js +1 -0
  56. package/assets/board/_nuxt/z9IMa9Iy.js +13 -0
  57. package/assets/board/burndown/index.html +1 -1
  58. package/assets/board/epics/index.html +1 -1
  59. package/assets/board/gantt/index.html +1 -1
  60. package/assets/board/index.html +1 -1
  61. package/assets/board/milestones/index.html +1 -1
  62. package/bin/scrum-skills.mjs +33 -10
  63. package/package.json +1 -1
  64. package/templates/AGENTS.md +1 -1
  65. package/tools/ai.mjs +24 -2
  66. package/tools/projects.mjs +46 -11
  67. package/tools/serve.mjs +1 -1
  68. package/tools/validate.mjs +21 -7
  69. package/assets/board/_nuxt/BpR5gMZX.js +0 -95
  70. package/assets/board/_nuxt/BvaAl29K.js +0 -13
  71. package/assets/board/_nuxt/CDL4GvLz.js +0 -1
  72. package/assets/board/_nuxt/CTOFhZkc.js +0 -1
  73. package/assets/board/_nuxt/CZc2Mypg.js +0 -1
  74. package/assets/board/_nuxt/Cw7Z2eHM.js +0 -1
  75. package/assets/board/_nuxt/DWxQkuvT.js +0 -1
  76. package/assets/board/_nuxt/Dpzighda.js +0 -1
  77. package/assets/board/_nuxt/DttMo5b5.js +0 -1
  78. package/assets/board/_nuxt/FCuSfyjP.js +0 -1
  79. package/assets/board/_nuxt/builds/meta/24603984-d514-4dff-badc-898a1fd5e770.json +0 -1
  80. package/assets/board/_nuxt/entry.ChOvfapw.css +0 -2
  81. package/assets/board/_nuxt/milestones.CVvAMKg4.css +0 -1
  82. package/assets/board/_nuxt/pages.DSSttn9g.css +0 -1
package/tools/ai.mjs CHANGED
@@ -8,6 +8,8 @@
8
8
  * 边收 SSE 边把 text / reasoning / tool 事件吐给前端,浏览器不必碰 Key 也不受 CORS 限制。
9
9
  * 3. 工具循环:模型要 search_agile / update_agile 就执行,结果回灌再来一轮;
10
10
  * 到轮数上限强制收口(最后一轮不带工具),否则模型会一直调下去。
11
+ * 4. 多模态:前端可以把图片按 OpenAI 的 image_url 一起塞进 user content 数组,
12
+ * 这里原样透传;图片没有文字可估,按 IMAGE_TOKEN_ESTIMATE 计入上下文预算。
11
13
  *
12
14
  * 审核策略(reviewMode)在这里落地:manual 只调 tools.propose 产待审变更,
13
15
  * auto 直接 tools.patch 落盘(写入方自己带校验与回滚)。
@@ -42,6 +44,8 @@ export const BASE_SYSTEM = [
42
44
  '2. 不要凭空编 id、状态、日期、人名,也不要造测试数据。',
43
45
  '3. 改数据前用一句话说明要改什么,改完说明改了哪几条。',
44
46
  '4. 用简体中文,直接给结论:不复述问题、不做自我介绍、不寒暄。',
47
+ '5. 故事状态只有 draft / ready / in-progress / review / done / cancelled(已取消)这六种:'
48
+ + 'cancelled 是终态,表示这事不做了,要在回答里说清为什么取消;不做了的故事就标 cancelled,不要塞进 done 充数。',
45
49
  ].join('\n');
46
50
 
47
51
  export const DEFAULT_CONFIG = {
@@ -123,7 +127,21 @@ export function estimateTokens(text) {
123
127
  return cjk * 0.7 + rest / 4;
124
128
  }
125
129
 
126
- const costOf = messages => messages.reduce((sum, m) => sum + estimateTokens(m.content) + 8, 0);
130
+ /**
131
+ * 一张图在请求里没有文字可估,按固定额度算。
132
+ * 不估(当成 0)会让「塞一堆截图」的对话永远不触发裁剪,请求体越滚越大直到接口 413;
133
+ * 估得偏高则是安全的:多裁几轮老对话,总比请求发不出去强。
134
+ */
135
+ export const IMAGE_TOKEN_ESTIMATE = 800;
136
+
137
+ const contentCost = content => (Array.isArray(content)
138
+ ? content.reduce(
139
+ (sum, part) => sum + (part?.type === 'image_url' ? IMAGE_TOKEN_ESTIMATE : estimateTokens(part?.text ?? '')),
140
+ 0,
141
+ )
142
+ : estimateTokens(content));
143
+
144
+ const costOf = messages => messages.reduce((sum, m) => sum + contentCost(m.content) + 8, 0);
127
145
 
128
146
  /**
129
147
  * 上下文预算:保留系统提示词与最后一块(当前提问),从最早处整块丢弃。
@@ -328,7 +346,11 @@ export function createAiService({ root, store = defaultStore, token = '', tools,
328
346
  });
329
347
  if (!response.ok) {
330
348
  const detail = await response.text().catch(() => '');
331
- yield { type: 'error', message: `模型接口返回 ${response.status}:${detail.slice(0, 300) || response.statusText}` };
349
+ // 带图被拒最常见的原因是模型不是多模态:DeepSeek 的 chat / reasoner 都不收 image_url。
350
+ // 不点破的话用户只看到一行 400,会以为是 Key 或网络问题。
351
+ const withImages = convo.some(m => Array.isArray(m.content) && m.content.some(p => p?.type === 'image_url'));
352
+ const hint = withImages ? '(这一轮带了图片:当前模型可能不支持图片输入,请在设置里换成支持视觉模型的接口,或去掉图片再发)' : '';
353
+ yield { type: 'error', message: `模型接口返回 ${response.status}:${detail.slice(0, 300) || response.statusText}${hint}` };
332
354
  return;
333
355
  }
334
356
 
@@ -5,6 +5,7 @@ import { execFile, execFileSync } from 'node:child_process';
5
5
  import { promisify } from 'node:util';
6
6
  import { randomBytes } from 'node:crypto';
7
7
  import { fileURLToPath } from 'node:url';
8
+ import { createAgileTools } from './agile-tools.mjs';
8
9
 
9
10
  const exec = promisify(execFile);
10
11
  const validator = fileURLToPath(new URL('./validate.mjs', import.meta.url));
@@ -58,6 +59,24 @@ try {
58
59
  }
59
60
  }
60
61
 
62
+ /**
63
+ * 故事状态单向车道。看板卡片的「下一步 / 退回」就按这条线走,
64
+ * done 是终点、cancelled 是另一条终点,两者都不许再动。
65
+ */
66
+ export const STORY_FLOW = ['draft', 'ready', 'in-progress', 'review', 'done'];
67
+
68
+ const ACTION_LABEL = { next: '推进到下一状态', prev: '退回上一状态', cancel: '标记为已取消' };
69
+
70
+ /** 按动作算出目标状态;算不出来(已是终点 / 动作不认识)返回 null */
71
+ export function statusAfter(from, action) {
72
+ if (action === 'cancel') return ['draft', 'ready', 'in-progress', 'review'].includes(from) ? 'cancelled' : null;
73
+ if (!STORY_FLOW.includes(from)) return null;
74
+ const i = STORY_FLOW.indexOf(from);
75
+ if (action === 'next') return STORY_FLOW[i + 1] ?? null;
76
+ if (action === 'prev') return STORY_FLOW[i - 1] ?? null;
77
+ return null;
78
+ }
79
+
61
80
  function snapshot(root) {
62
81
  const read = file => JSON.parse(readFileSync(join(root, 'agile', file + '.json'), 'utf8'));
63
82
  return { backlog: read('backlog'), sprints: read('sprints'), journal: read('journal'), meta: { root, servedAt: new Date().toISOString() } };
@@ -78,18 +97,12 @@ export function createProjects({ root, store = defaultStore, pick = pickDirector
78
97
  const saved = JSON.parse(readFileSync(store, 'utf8'));
79
98
  if (Array.isArray(saved.recent)) recent = saved.recent.filter(x => typeof x === 'string').slice(0, 10);
80
99
  } catch {}
100
+ // 启动目录优先:从哪个目录起服务,看板就打开哪个目录。
101
+ // 最近列表只是「切回上次那个项目」的入口,不许顶替启动目录——
102
+ // 否则在 A 项目里 serve,会悄悄打开上一次的 B 项目数据(JSON 看着像是 A 的,其实全错)。
81
103
  const initial = realpathSync(root);
82
104
  let current = initial;
83
- for (const candidate of [...recent, initial]) {
84
- try {
85
- const path = realpathSync(candidate);
86
- validate(path);
87
- snapshot(path);
88
- current = path;
89
- break;
90
- } catch {}
91
- }
92
- recent = [...new Set([current, ...recent, initial])].slice(0, 10);
105
+ recent = [...new Set([current, ...recent])].slice(0, 10);
93
106
  let selecting = false;
94
107
  const list = () => ({ recent: [...recent], root: current, token });
95
108
  const read = (path = current) => {
@@ -115,7 +128,25 @@ export function createProjects({ root, store = defaultStore, pick = pickDirector
115
128
  return selected ? await open(selected) : null;
116
129
  } finally { selecting = false; }
117
130
  };
118
- return { list, read, open, select };
131
+ // 写盘复用 AI 助手那套「文本补丁 + 写后校验 + 失败回滚」,
132
+ // 不另写一份:改一个状态只差一行字符,校验不过自动还原。
133
+ const tools = createAgileTools({ root: () => current });
134
+ const setStoryStatus = ({ id, action } = {}) => {
135
+ if (!id) throw new Error('缺少故事 id');
136
+ const { backlog } = snapshot(current);
137
+ const story = (backlog.stories ?? []).find(s => s.id === id);
138
+ if (!story) throw new Error(`找不到故事 ${id}`);
139
+ const from = story.status;
140
+ const to = statusAfter(from, action);
141
+ if (!to) throw new Error(`${id} 当前是 ${from},没法${ACTION_LABEL[action] ?? `执行 ${action}`}`);
142
+ const patches = [{ path: `stories.${id}.status`, value: to }];
143
+ // 取消一条还被阻塞的故事:阻塞标记要一起摘掉。
144
+ // 校验器不许「已取消却还标着 blocked」,不摘会整条改动作废。
145
+ if (to === 'cancelled' && story.blocked) patches.push({ op: 'remove', path: `stories.${id}.blocked` });
146
+ tools.patch({ file: 'backlog', patches, reason: `看板卡片操作:${from} → ${to}` });
147
+ return { id, from, to };
148
+ };
149
+ return { list, read, open, select, setStoryStatus };
119
150
  }
120
151
 
121
152
  export async function handleProjectApi(projects, { method, path, headers, body }) {
@@ -130,6 +161,10 @@ export async function handleProjectApi(projects, { method, path, headers, body }
130
161
  if (method === 'GET' && url.pathname === '/api/agile') return { status: 200, body: projects.read(url.searchParams.get('root') || undefined) };
131
162
  if (method !== 'POST') return { status: 405, body: { error: '不支持的请求方法。' } };
132
163
  if (headers['x-project-token'] !== projects.list().token) return denied();
164
+ // 看板卡片的退回 / 下一步 / 取消:改的是当前打开项目的 backlog.json
165
+ if (url.pathname === '/api/agile/story-status') {
166
+ return { status: 200, body: projects.setStoryStatus({ id: body?.id, action: body?.action }) };
167
+ }
133
168
  if (url.pathname === '/api/projects/select') return { status: 200, body: await projects.select() };
134
169
  if (url.pathname === '/api/projects/open') {
135
170
  if (!projects.list().recent.includes(body?.root)) throw new Error('该项目不在最近列表中,请选择文件夹。');
package/tools/serve.mjs CHANGED
@@ -112,7 +112,7 @@ const server = createServer(async (req, res) => {
112
112
  return;
113
113
  }
114
114
 
115
- if (pathname === '/api/agile' || pathname.startsWith('/api/projects')) {
115
+ if (pathname.startsWith('/api/agile') || pathname.startsWith('/api/projects')) {
116
116
  try {
117
117
  let text = '';
118
118
  for await (const chunk of req) {
@@ -50,7 +50,9 @@ const sprints = Array.isArray(sprintsFile.sprints) ? sprintsFile.sprints : [];
50
50
  const milestones = Array.isArray(sprintsFile.milestones) ? sprintsFile.milestones : [];
51
51
  const journals = Array.isArray(journalFile.journals) ? journalFile.journals : [];
52
52
 
53
- const STORY_FLOW = ['draft', 'ready', 'in-progress', 'review', 'done'];
53
+ const STORY_FLOW = ['draft', 'ready', 'in-progress', 'review', 'done', 'cancelled'];
54
+ // 终态:done 与 cancelled 都不再往前走,也不再要求验收 / 任务全部做完
55
+ const STORY_TERMINAL = ['done', 'cancelled'];
54
56
  const TASK_FLOW = ['todo', 'red', 'green', 'refactored', 'done'];
55
57
  const EPIC_FLOW = ['draft', 'active', 'done', 'cancelled'];
56
58
  const MILESTONE_FLOW = ['planned', 'at-risk', 'reached', 'missed'];
@@ -136,7 +138,8 @@ for (const s of stories) {
136
138
  else if (!(epicById.get(epicId).stories ?? []).includes(id))
137
139
  err(`story ${id}: 挂在 ${epicId} 上,但该 epic 的 stories 里没有它(双向引用不一致)`);
138
140
 
139
- if (!STORY_FLOW.includes(status) && status !== 'cancelled') err(`story ${id}: 非法状态 ${status}`);
141
+ if (!STORY_FLOW.includes(status))
142
+ err(`story ${id}: 非法状态 ${status}(合法:${STORY_FLOW.join('/')})`);
140
143
  if (!Number.isFinite(points)) err(`story ${id}: points 必须是数字`);
141
144
  else if (![1, 2, 3, 5, 8, 13].includes(points)) warn(`story ${id}: points ${points} 不在斐波那契序列`);
142
145
 
@@ -153,7 +156,8 @@ for (const s of stories) {
153
156
  }
154
157
  }
155
158
 
156
- if ((status === 'ready' || STORY_FLOW.indexOf(status) > 1) && (!acceptance || acceptance.length === 0))
159
+ // 取消掉的故事不会再交付,别逼着为它补验收标准
160
+ if (['ready', 'in-progress', 'review', 'done'].includes(status) && (!acceptance || acceptance.length === 0))
157
161
  err(`story ${id}: 状态 ${status} 但验收标准为空`);
158
162
  for (const [i, a] of (acceptance ?? []).entries()) {
159
163
  if (!a.given || !a.when || !a.then) err(`story ${id}: 第 ${i + 1} 条验收标准缺 given/when/then`);
@@ -163,7 +167,8 @@ for (const s of stories) {
163
167
  if (!hit) err(`story ${id}: 第 ${i + 1} 条验收的 verifyBy ${tid} 不存在`);
164
168
  else if (hit.story.id !== id) err(`story ${id}: 第 ${i + 1} 条验收的 verifyBy ${tid} 属于 ${hit.story.id}`);
165
169
  }
166
- if (!(a.verifyBy ?? []).length && status !== 'draft') warn(`story ${id}: 第 ${i + 1} 条验收没有 verifyBy(没绑定验证任务)`);
170
+ if (!(a.verifyBy ?? []).length && !['draft', 'cancelled'].includes(status))
171
+ warn(`story ${id}: 第 ${i + 1} 条验收没有 verifyBy(没绑定验证任务)`);
167
172
  }
168
173
 
169
174
  // 阻塞 / 依赖
@@ -175,7 +180,9 @@ for (const s of stories) {
175
180
  if (!s.blocked.reason) err(`story ${id}: blocked 缺少 reason(堵在哪要说清)`);
176
181
  if (!DATE_RE.test(s.blocked.since ?? '')) err(`story ${id}: blocked.since 格式应为 YYYY-MM-DD`);
177
182
  if (status === 'done') err(`story ${id}: 已 done 却还标着 blocked`);
178
- if (sprintId) warn(`story ${id}: 处于 ${sprintId} 但被阻塞(${s.blocked.reason}),站会必须处理`);
183
+ if (status === 'cancelled') err(`story ${id}: 已取消却还标着 blocked(不做了就别再挂着阻塞)`);
184
+ if (sprintId && !STORY_TERMINAL.includes(status))
185
+ warn(`story ${id}: 处于 ${sprintId} 但被阻塞(${s.blocked.reason}),站会必须处理`);
179
186
  }
180
187
 
181
188
  // 范围变更标记
@@ -191,7 +198,8 @@ for (const s of stories) {
191
198
  if (!sp) err(`story ${id}: sprintId ${sprintId} 不存在`);
192
199
  else if (!(sp.stories ?? []).includes(id))
193
200
  err(`story ${id}: 指向 ${sprintId},但该 sprint 的 stories 里没有它(双向引用不一致)`);
194
- if (!acceptance || acceptance.length === 0) err(`story ${id}: 已进 sprint 但无验收标准(不满足 ready)`);
201
+ if (status !== 'cancelled' && (!acceptance || acceptance.length === 0))
202
+ err(`story ${id}: 已进 sprint 但无验收标准(不满足 ready)`);
195
203
  }
196
204
 
197
205
  if (s.schedule) {
@@ -230,8 +238,14 @@ for (const sp of sprints) {
230
238
  const st = storyById.get(sid);
231
239
  if (!st) { err(`sprint ${sp.id}: 引用 story ${sid} 不存在`); continue; }
232
240
  if (st.sprintId !== sp.id) err(`sprint ${sp.id}: story ${sid} 的 sprintId 是 ${st.sprintId},双向引用不一致`);
241
+ if (st.status === 'cancelled')
242
+ warn(`story ${sid}: 已取消但仍排在 ${sp.id} 里,建议从迭代移出(点数已不计入承诺)`);
233
243
  }
234
- const committed = (sp.stories ?? []).reduce((a, sid) => a + (storyById.get(sid)?.points ?? 0), 0);
244
+ // 已取消的故事不再占迭代容量:承诺点数只算还可能交付的
245
+ const committed = (sp.stories ?? []).reduce((a, sid) => {
246
+ const st = storyById.get(sid);
247
+ return st && st.status !== 'cancelled' ? a + (st.points ?? 0) : a;
248
+ }, 0);
235
249
  if (Number.isFinite(sp.capacity) && committed > sp.capacity)
236
250
  warn(`sprint ${sp.id}: 承诺 ${committed} 点超过容量 ${sp.capacity} 点`);
237
251
  if (sp.start <= today && sp.end >= today) active++;