forgehive 0.7.7 → 0.7.8
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/README.md +5 -3
- package/dist/cli.js +9 -31
- package/docs/user-guide.md +8 -3
- package/forgehive/commands/fh-refactor.md +116 -0
- package/forgehive/commands/review-party.md +42 -23
- package/forgehive/party/defaults.yaml +8 -0
- package/package.json +2 -2
- package/docs/superpowers/plans/2026-05-11-nextgen-v04-v05.md +0 -1708
- package/docs/superpowers/plans/2026-05-11-v05-nextgen-gaps.md +0 -2364
- package/docs/superpowers/plans/2026-05-14-sprint-planner-v2.md +0 -1058
- package/docs/superpowers/plans/2026-05-14-v07-nextgen.md +0 -1980
|
@@ -1,1058 +0,0 @@
|
|
|
1
|
-
# Sprint Planner v2 — Story Cards, Epics, Velocity
|
|
2
|
-
|
|
3
|
-
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task.
|
|
4
|
-
|
|
5
|
-
**Goal:** Extend forgehive's sprint planning with structured Story Cards (User Stories + AC), Epic hierarchy, and Velocity Tracking — all stored as Markdown files in `.forgehive/memory/`.
|
|
6
|
-
|
|
7
|
-
**Architecture:** Three new modules (`src/stories.ts`, `src/epics.ts`, `src/velocity.ts`) with corresponding test files. Stories and Epics are persisted as individual Markdown files with YAML frontmatter. Velocity is a single append-only Markdown table. CLI commands: `fh story`, `fh epic`, `fh velocity`. `/fh-sprint` is enhanced to load backlog stories, assign Fibonacci points, and write velocity on completion.
|
|
8
|
-
|
|
9
|
-
**Tech Stack:** TypeScript ESM, Node.js ≥ 18, `js-yaml`, `node:fs`, `node:path` — no new runtime dependencies.
|
|
10
|
-
|
|
11
|
-
---
|
|
12
|
-
|
|
13
|
-
## File Map
|
|
14
|
-
|
|
15
|
-
**New source files:**
|
|
16
|
-
- `src/stories.ts` — Story Card CRUD + formatting
|
|
17
|
-
- `src/epics.ts` — Epic CRUD + formatting
|
|
18
|
-
- `src/velocity.ts` — Velocity tracking + rolling average
|
|
19
|
-
|
|
20
|
-
**New test files:**
|
|
21
|
-
- `test/stories.test.ts`
|
|
22
|
-
- `test/epics.test.ts`
|
|
23
|
-
- `test/velocity.test.ts`
|
|
24
|
-
|
|
25
|
-
**Modified files:**
|
|
26
|
-
- `src/cli.ts` — wire `fh story`, `fh epic`, `fh velocity`
|
|
27
|
-
- `forgehive/commands/fh-sprint.md` — Fibonacci points + story loading + velocity recording
|
|
28
|
-
|
|
29
|
-
---
|
|
30
|
-
|
|
31
|
-
## Task 1: Story Cards (`src/stories.ts`)
|
|
32
|
-
|
|
33
|
-
**Files:**
|
|
34
|
-
- Create: `src/stories.ts`
|
|
35
|
-
- Create: `test/stories.test.ts`
|
|
36
|
-
- Modify: `src/cli.ts`
|
|
37
|
-
|
|
38
|
-
- [ ] **Step 1: Write the failing test**
|
|
39
|
-
|
|
40
|
-
```typescript
|
|
41
|
-
// test/stories.test.ts
|
|
42
|
-
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
43
|
-
import assert from "node:assert/strict";
|
|
44
|
-
import fs from "node:fs";
|
|
45
|
-
import path from "node:path";
|
|
46
|
-
import os from "node:os";
|
|
47
|
-
import {
|
|
48
|
-
createStory,
|
|
49
|
-
listStories,
|
|
50
|
-
getStory,
|
|
51
|
-
updateStoryPoints,
|
|
52
|
-
updateStoryStatus,
|
|
53
|
-
formatStoryCard,
|
|
54
|
-
} from "../src/stories.ts";
|
|
55
|
-
|
|
56
|
-
describe("createStory()", () => {
|
|
57
|
-
let tmpDir: string;
|
|
58
|
-
beforeEach(() => {
|
|
59
|
-
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "fh-stories-"));
|
|
60
|
-
});
|
|
61
|
-
afterEach(() => fs.rmSync(tmpDir, { recursive: true, force: true }));
|
|
62
|
-
|
|
63
|
-
it("creates a story file and returns the story", () => {
|
|
64
|
-
const story = createStory(tmpDir, "Als Nutzer möchte ich mich einloggen");
|
|
65
|
-
assert.ok(story.id.startsWith("US-"));
|
|
66
|
-
assert.equal(story.title, "Als Nutzer möchte ich mich einloggen");
|
|
67
|
-
assert.equal(story.status, "backlog");
|
|
68
|
-
assert.ok(fs.existsSync(path.join(tmpDir, `${story.id}.md`)));
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
it("auto-increments story IDs", () => {
|
|
72
|
-
const s1 = createStory(tmpDir, "Story one");
|
|
73
|
-
const s2 = createStory(tmpDir, "Story two");
|
|
74
|
-
assert.notEqual(s1.id, s2.id);
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
it("assigns epicId when provided", () => {
|
|
78
|
-
const story = createStory(tmpDir, "Story with epic", "EPC-1");
|
|
79
|
-
assert.equal(story.epicId, "EPC-1");
|
|
80
|
-
});
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
describe("listStories()", () => {
|
|
84
|
-
let tmpDir: string;
|
|
85
|
-
beforeEach(() => {
|
|
86
|
-
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "fh-stories-"));
|
|
87
|
-
});
|
|
88
|
-
afterEach(() => fs.rmSync(tmpDir, { recursive: true, force: true }));
|
|
89
|
-
|
|
90
|
-
it("returns all stories from directory", () => {
|
|
91
|
-
createStory(tmpDir, "Story A");
|
|
92
|
-
createStory(tmpDir, "Story B");
|
|
93
|
-
const stories = listStories(tmpDir);
|
|
94
|
-
assert.equal(stories.length, 2);
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
it("returns empty array when directory is empty", () => {
|
|
98
|
-
const stories = listStories(tmpDir);
|
|
99
|
-
assert.equal(stories.length, 0);
|
|
100
|
-
});
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
describe("updateStoryPoints()", () => {
|
|
104
|
-
let tmpDir: string;
|
|
105
|
-
beforeEach(() => {
|
|
106
|
-
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "fh-stories-"));
|
|
107
|
-
});
|
|
108
|
-
afterEach(() => fs.rmSync(tmpDir, { recursive: true, force: true }));
|
|
109
|
-
|
|
110
|
-
it("updates points and persists", () => {
|
|
111
|
-
const story = createStory(tmpDir, "Pointable story");
|
|
112
|
-
updateStoryPoints(tmpDir, story.id, 5);
|
|
113
|
-
const loaded = getStory(tmpDir, story.id);
|
|
114
|
-
assert.equal(loaded?.points, 5);
|
|
115
|
-
});
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
describe("formatStoryCard()", () => {
|
|
119
|
-
it("produces markdown with story ID and title", () => {
|
|
120
|
-
const story = {
|
|
121
|
-
id: "US-1",
|
|
122
|
-
title: "Login feature",
|
|
123
|
-
asA: "Nutzer",
|
|
124
|
-
iWant: "mich einloggen",
|
|
125
|
-
soThat: "ich mein Konto sehen kann",
|
|
126
|
-
acceptanceCriteria: ["Login-Formular ist sichtbar", "Falsche Credentials zeigen Fehler"],
|
|
127
|
-
points: 3,
|
|
128
|
-
epicId: null,
|
|
129
|
-
status: "backlog" as const,
|
|
130
|
-
};
|
|
131
|
-
const md = formatStoryCard(story);
|
|
132
|
-
assert.ok(md.includes("US-1"));
|
|
133
|
-
assert.ok(md.includes("Login feature"));
|
|
134
|
-
assert.ok(md.includes("3"));
|
|
135
|
-
});
|
|
136
|
-
});
|
|
137
|
-
```
|
|
138
|
-
|
|
139
|
-
- [ ] **Step 2: Run test to verify it fails**
|
|
140
|
-
|
|
141
|
-
```bash
|
|
142
|
-
cd /home/stefan/projekte/forgehive
|
|
143
|
-
node --import tsx/esm --test test/stories.test.ts
|
|
144
|
-
```
|
|
145
|
-
Expected: FAIL — `Cannot find module '../src/stories.ts'`
|
|
146
|
-
|
|
147
|
-
- [ ] **Step 3: Write the implementation**
|
|
148
|
-
|
|
149
|
-
```typescript
|
|
150
|
-
// src/stories.ts
|
|
151
|
-
import fs from "node:fs";
|
|
152
|
-
import path from "node:path";
|
|
153
|
-
import yaml from "js-yaml";
|
|
154
|
-
|
|
155
|
-
export interface Story {
|
|
156
|
-
id: string;
|
|
157
|
-
title: string;
|
|
158
|
-
asA: string;
|
|
159
|
-
iWant: string;
|
|
160
|
-
soThat: string;
|
|
161
|
-
acceptanceCriteria: string[];
|
|
162
|
-
points: number | null;
|
|
163
|
-
epicId: string | null;
|
|
164
|
-
status: "backlog" | "in-sprint" | "done";
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
function nextStoryId(storiesDir: string): string {
|
|
168
|
-
if (!fs.existsSync(storiesDir)) return "US-1";
|
|
169
|
-
const existing = fs.readdirSync(storiesDir)
|
|
170
|
-
.filter((f) => f.match(/^US-\d+\.md$/))
|
|
171
|
-
.map((f) => parseInt(f.replace("US-", "").replace(".md", ""), 10))
|
|
172
|
-
.filter((n) => !isNaN(n));
|
|
173
|
-
const max = existing.length > 0 ? Math.max(...existing) : 0;
|
|
174
|
-
return `US-${max + 1}`;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
function storyToMarkdown(story: Story): string {
|
|
178
|
-
const frontmatter = yaml.dump({
|
|
179
|
-
id: story.id,
|
|
180
|
-
title: story.title,
|
|
181
|
-
asA: story.asA,
|
|
182
|
-
iWant: story.iWant,
|
|
183
|
-
soThat: story.soThat,
|
|
184
|
-
points: story.points,
|
|
185
|
-
epicId: story.epicId,
|
|
186
|
-
status: story.status,
|
|
187
|
-
});
|
|
188
|
-
const acLines = story.acceptanceCriteria.map((c) => `- [ ] ${c}`).join("\n");
|
|
189
|
-
return `---\n${frontmatter}---\n\n# ${story.id}: ${story.title}\n\n## User Story\n\nAls **${story.asA}** möchte ich **${story.iWant}**, damit **${story.soThat}**.\n\n## Acceptance Criteria\n\n${acLines || "- [ ] (noch nicht definiert)"}\n`;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
function parseStoryFile(filePath: string): Story | null {
|
|
193
|
-
try {
|
|
194
|
-
const content = fs.readFileSync(filePath, "utf8");
|
|
195
|
-
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
196
|
-
if (!match) return null;
|
|
197
|
-
const data = yaml.load(match[1]) as any;
|
|
198
|
-
return {
|
|
199
|
-
id: data.id ?? "",
|
|
200
|
-
title: data.title ?? "",
|
|
201
|
-
asA: data.asA ?? "",
|
|
202
|
-
iWant: data.iWant ?? "",
|
|
203
|
-
soThat: data.soThat ?? "",
|
|
204
|
-
acceptanceCriteria: data.acceptanceCriteria ?? [],
|
|
205
|
-
points: data.points ?? null,
|
|
206
|
-
epicId: data.epicId ?? null,
|
|
207
|
-
status: data.status ?? "backlog",
|
|
208
|
-
};
|
|
209
|
-
} catch {
|
|
210
|
-
return null;
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
export function createStory(
|
|
215
|
-
storiesDir: string,
|
|
216
|
-
title: string,
|
|
217
|
-
epicId?: string
|
|
218
|
-
): Story {
|
|
219
|
-
fs.mkdirSync(storiesDir, { recursive: true });
|
|
220
|
-
const id = nextStoryId(storiesDir);
|
|
221
|
-
const story: Story = {
|
|
222
|
-
id,
|
|
223
|
-
title,
|
|
224
|
-
asA: "",
|
|
225
|
-
iWant: title,
|
|
226
|
-
soThat: "",
|
|
227
|
-
acceptanceCriteria: [],
|
|
228
|
-
points: null,
|
|
229
|
-
epicId: epicId ?? null,
|
|
230
|
-
status: "backlog",
|
|
231
|
-
};
|
|
232
|
-
fs.writeFileSync(path.join(storiesDir, `${id}.md`), storyToMarkdown(story), "utf8");
|
|
233
|
-
return story;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
export function listStories(storiesDir: string): Story[] {
|
|
237
|
-
if (!fs.existsSync(storiesDir)) return [];
|
|
238
|
-
return fs.readdirSync(storiesDir)
|
|
239
|
-
.filter((f) => f.match(/^US-\d+\.md$/))
|
|
240
|
-
.map((f) => parseStoryFile(path.join(storiesDir, f)))
|
|
241
|
-
.filter((s): s is Story => s !== null)
|
|
242
|
-
.sort((a, b) => {
|
|
243
|
-
const na = parseInt(a.id.replace("US-", ""), 10);
|
|
244
|
-
const nb = parseInt(b.id.replace("US-", ""), 10);
|
|
245
|
-
return na - nb;
|
|
246
|
-
});
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
export function getStory(storiesDir: string, id: string): Story | null {
|
|
250
|
-
const filePath = path.join(storiesDir, `${id}.md`);
|
|
251
|
-
if (!fs.existsSync(filePath)) return null;
|
|
252
|
-
return parseStoryFile(filePath);
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
export function updateStoryPoints(storiesDir: string, id: string, points: number): void {
|
|
256
|
-
const story = getStory(storiesDir, id);
|
|
257
|
-
if (!story) throw new Error(`Story ${id} nicht gefunden`);
|
|
258
|
-
story.points = points;
|
|
259
|
-
fs.writeFileSync(path.join(storiesDir, `${id}.md`), storyToMarkdown(story), "utf8");
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
export function updateStoryStatus(
|
|
263
|
-
storiesDir: string,
|
|
264
|
-
id: string,
|
|
265
|
-
status: Story["status"]
|
|
266
|
-
): void {
|
|
267
|
-
const story = getStory(storiesDir, id);
|
|
268
|
-
if (!story) throw new Error(`Story ${id} nicht gefunden`);
|
|
269
|
-
story.status = status;
|
|
270
|
-
fs.writeFileSync(path.join(storiesDir, `${id}.md`), storyToMarkdown(story), "utf8");
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
export function formatStoryCard(story: Story): string {
|
|
274
|
-
const points = story.points !== null ? ` · ${story.points} Punkte` : "";
|
|
275
|
-
const epic = story.epicId ? ` · ${story.epicId}` : "";
|
|
276
|
-
const lines: string[] = [];
|
|
277
|
-
lines.push(`## ${story.id}: ${story.title}${points}${epic}`);
|
|
278
|
-
lines.push(`**Status:** ${story.status}`);
|
|
279
|
-
if (story.asA || story.iWant)
|
|
280
|
-
lines.push(`**Story:** Als ${story.asA || "Nutzer"} möchte ich ${story.iWant}${story.soThat ? `, damit ${story.soThat}` : ""}.`);
|
|
281
|
-
if (story.acceptanceCriteria.length > 0) {
|
|
282
|
-
lines.push("**Acceptance Criteria:**");
|
|
283
|
-
for (const ac of story.acceptanceCriteria) lines.push(`- ${ac}`);
|
|
284
|
-
}
|
|
285
|
-
return lines.join("\n");
|
|
286
|
-
}
|
|
287
|
-
```
|
|
288
|
-
|
|
289
|
-
- [ ] **Step 4: Run test to verify it passes**
|
|
290
|
-
|
|
291
|
-
```bash
|
|
292
|
-
node --import tsx/esm --test test/stories.test.ts
|
|
293
|
-
```
|
|
294
|
-
Expected: 8 passing
|
|
295
|
-
|
|
296
|
-
- [ ] **Step 5: Wire `fh story` in cli.ts**
|
|
297
|
-
|
|
298
|
-
Add import:
|
|
299
|
-
```typescript
|
|
300
|
-
import { createStory, listStories, getStory, updateStoryPoints, updateStoryStatus, formatStoryCard } from "./stories.ts";
|
|
301
|
-
```
|
|
302
|
-
|
|
303
|
-
Add handler (use `const storiesDir = path.join(forgehiveDir, "memory", "stories");`):
|
|
304
|
-
```typescript
|
|
305
|
-
} else if (command === "story") {
|
|
306
|
-
const storiesDir = path.join(forgehiveDir, "memory", "stories");
|
|
307
|
-
if (subcommand === "create") {
|
|
308
|
-
const title = rest.filter((r) => !r.startsWith("--")).join(" ");
|
|
309
|
-
const epicArg = rest.includes("--epic") ? rest[rest.indexOf("--epic") + 1] : undefined;
|
|
310
|
-
const pointsArg = rest.includes("--points") ? parseInt(rest[rest.indexOf("--points") + 1], 10) : undefined;
|
|
311
|
-
if (!title) { console.error("Usage: fh story create <titel> [--epic EPC-N] [--points N]"); process.exit(1); }
|
|
312
|
-
const story = createStory(storiesDir, title, epicArg);
|
|
313
|
-
if (pointsArg) updateStoryPoints(storiesDir, story.id, pointsArg);
|
|
314
|
-
console.log(`✔ ${story.id} erstellt: ${path.join(storiesDir, story.id + ".md")}`);
|
|
315
|
-
console.log(` Bearbeite die Datei um Acceptance Criteria hinzuzufügen.`);
|
|
316
|
-
} else if (subcommand === "list") {
|
|
317
|
-
const epicFilter = rest.includes("--epic") ? rest[rest.indexOf("--epic") + 1] : null;
|
|
318
|
-
let stories = listStories(storiesDir);
|
|
319
|
-
if (epicFilter) stories = stories.filter((s) => s.epicId === epicFilter);
|
|
320
|
-
if (stories.length === 0) { console.log("Keine Stories gefunden."); }
|
|
321
|
-
else {
|
|
322
|
-
for (const s of stories) {
|
|
323
|
-
const pts = s.points !== null ? ` [${s.points}pt]` : " [?pt]";
|
|
324
|
-
const epic = s.epicId ? ` (${s.epicId})` : "";
|
|
325
|
-
console.log(` ${s.id}${pts}${epic} — ${s.title} [${s.status}]`);
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
} else if (subcommand === "done") {
|
|
329
|
-
const id = rest[0];
|
|
330
|
-
const pointsArg = rest.includes("--points") ? parseInt(rest[rest.indexOf("--points") + 1], 10) : undefined;
|
|
331
|
-
if (!id) { console.error("Usage: fh story done <US-N> [--points N]"); process.exit(1); }
|
|
332
|
-
if (pointsArg) updateStoryPoints(storiesDir, id, pointsArg);
|
|
333
|
-
updateStoryStatus(storiesDir, id, "done");
|
|
334
|
-
console.log(`✔ ${id} als done markiert`);
|
|
335
|
-
} else if (subcommand === "show") {
|
|
336
|
-
const id = rest[0];
|
|
337
|
-
if (!id) { console.error("Usage: fh story show <US-N>"); process.exit(1); }
|
|
338
|
-
const story = getStory(storiesDir, id);
|
|
339
|
-
if (!story) { console.error(`Story ${id} nicht gefunden`); process.exit(1); }
|
|
340
|
-
console.log(formatStoryCard(story));
|
|
341
|
-
} else {
|
|
342
|
-
console.error("Verfügbar: fh story create | list | show | done");
|
|
343
|
-
}
|
|
344
|
-
```
|
|
345
|
-
|
|
346
|
-
- [ ] **Step 6: Commit**
|
|
347
|
-
|
|
348
|
-
```bash
|
|
349
|
-
git add src/stories.ts test/stories.test.ts src/cli.ts
|
|
350
|
-
git commit -m "feat: add fh story command with Story Card management"
|
|
351
|
-
```
|
|
352
|
-
|
|
353
|
-
---
|
|
354
|
-
|
|
355
|
-
## Task 2: Epic Hierarchy (`src/epics.ts`)
|
|
356
|
-
|
|
357
|
-
**Files:**
|
|
358
|
-
- Create: `src/epics.ts`
|
|
359
|
-
- Create: `test/epics.test.ts`
|
|
360
|
-
- Modify: `src/cli.ts`
|
|
361
|
-
|
|
362
|
-
- [ ] **Step 1: Write the failing test**
|
|
363
|
-
|
|
364
|
-
```typescript
|
|
365
|
-
// test/epics.test.ts
|
|
366
|
-
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
367
|
-
import assert from "node:assert/strict";
|
|
368
|
-
import fs from "node:fs";
|
|
369
|
-
import path from "node:path";
|
|
370
|
-
import os from "node:os";
|
|
371
|
-
import { createEpic, listEpics, getEpic, addStoryToEpic, formatEpicCard } from "../src/epics.ts";
|
|
372
|
-
|
|
373
|
-
describe("createEpic()", () => {
|
|
374
|
-
let tmpDir: string;
|
|
375
|
-
beforeEach(() => {
|
|
376
|
-
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "fh-epics-"));
|
|
377
|
-
});
|
|
378
|
-
afterEach(() => fs.rmSync(tmpDir, { recursive: true, force: true }));
|
|
379
|
-
|
|
380
|
-
it("creates an epic file and returns the epic", () => {
|
|
381
|
-
const epic = createEpic(tmpDir, "User Authentication");
|
|
382
|
-
assert.ok(epic.id.startsWith("EPC-"));
|
|
383
|
-
assert.equal(epic.title, "User Authentication");
|
|
384
|
-
assert.equal(epic.status, "active");
|
|
385
|
-
assert.ok(fs.existsSync(path.join(tmpDir, `${epic.id}.md`)));
|
|
386
|
-
});
|
|
387
|
-
|
|
388
|
-
it("auto-increments epic IDs", () => {
|
|
389
|
-
const e1 = createEpic(tmpDir, "Epic One");
|
|
390
|
-
const e2 = createEpic(tmpDir, "Epic Two");
|
|
391
|
-
assert.notEqual(e1.id, e2.id);
|
|
392
|
-
});
|
|
393
|
-
|
|
394
|
-
it("stores goal when provided", () => {
|
|
395
|
-
const epic = createEpic(tmpDir, "Auth", "Nutzer können sich sicher einloggen");
|
|
396
|
-
assert.equal(epic.goal, "Nutzer können sich sicher einloggen");
|
|
397
|
-
});
|
|
398
|
-
});
|
|
399
|
-
|
|
400
|
-
describe("addStoryToEpic()", () => {
|
|
401
|
-
let tmpDir: string;
|
|
402
|
-
beforeEach(() => {
|
|
403
|
-
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "fh-epics-"));
|
|
404
|
-
});
|
|
405
|
-
afterEach(() => fs.rmSync(tmpDir, { recursive: true, force: true }));
|
|
406
|
-
|
|
407
|
-
it("adds story ID to epic and persists", () => {
|
|
408
|
-
const epic = createEpic(tmpDir, "Epic with stories");
|
|
409
|
-
addStoryToEpic(tmpDir, epic.id, "US-1");
|
|
410
|
-
addStoryToEpic(tmpDir, epic.id, "US-2");
|
|
411
|
-
const loaded = getEpic(tmpDir, epic.id);
|
|
412
|
-
assert.ok(loaded?.stories.includes("US-1"));
|
|
413
|
-
assert.ok(loaded?.stories.includes("US-2"));
|
|
414
|
-
});
|
|
415
|
-
});
|
|
416
|
-
|
|
417
|
-
describe("listEpics()", () => {
|
|
418
|
-
let tmpDir: string;
|
|
419
|
-
beforeEach(() => {
|
|
420
|
-
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "fh-epics-"));
|
|
421
|
-
});
|
|
422
|
-
afterEach(() => fs.rmSync(tmpDir, { recursive: true, force: true }));
|
|
423
|
-
|
|
424
|
-
it("returns all epics from directory", () => {
|
|
425
|
-
createEpic(tmpDir, "Epic A");
|
|
426
|
-
createEpic(tmpDir, "Epic B");
|
|
427
|
-
assert.equal(listEpics(tmpDir).length, 2);
|
|
428
|
-
});
|
|
429
|
-
});
|
|
430
|
-
|
|
431
|
-
describe("formatEpicCard()", () => {
|
|
432
|
-
it("produces markdown with epic ID and title", () => {
|
|
433
|
-
const epic = { id: "EPC-1", title: "Auth", goal: "Sicher einloggen", stories: ["US-1", "US-2"], status: "active" as const };
|
|
434
|
-
const md = formatEpicCard(epic);
|
|
435
|
-
assert.ok(md.includes("EPC-1"));
|
|
436
|
-
assert.ok(md.includes("Auth"));
|
|
437
|
-
assert.ok(md.includes("US-1"));
|
|
438
|
-
});
|
|
439
|
-
});
|
|
440
|
-
```
|
|
441
|
-
|
|
442
|
-
- [ ] **Step 2: Run test to verify it fails**
|
|
443
|
-
|
|
444
|
-
```bash
|
|
445
|
-
node --import tsx/esm --test test/epics.test.ts
|
|
446
|
-
```
|
|
447
|
-
Expected: FAIL
|
|
448
|
-
|
|
449
|
-
- [ ] **Step 3: Write the implementation**
|
|
450
|
-
|
|
451
|
-
```typescript
|
|
452
|
-
// src/epics.ts
|
|
453
|
-
import fs from "node:fs";
|
|
454
|
-
import path from "node:path";
|
|
455
|
-
import yaml from "js-yaml";
|
|
456
|
-
|
|
457
|
-
export interface Epic {
|
|
458
|
-
id: string;
|
|
459
|
-
title: string;
|
|
460
|
-
goal: string;
|
|
461
|
-
stories: string[];
|
|
462
|
-
status: "active" | "done";
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
function nextEpicId(epicsDir: string): string {
|
|
466
|
-
if (!fs.existsSync(epicsDir)) return "EPC-1";
|
|
467
|
-
const existing = fs.readdirSync(epicsDir)
|
|
468
|
-
.filter((f) => f.match(/^EPC-\d+\.md$/))
|
|
469
|
-
.map((f) => parseInt(f.replace("EPC-", "").replace(".md", ""), 10))
|
|
470
|
-
.filter((n) => !isNaN(n));
|
|
471
|
-
const max = existing.length > 0 ? Math.max(...existing) : 0;
|
|
472
|
-
return `EPC-${max + 1}`;
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
function epicToMarkdown(epic: Epic): string {
|
|
476
|
-
const frontmatter = yaml.dump({
|
|
477
|
-
id: epic.id,
|
|
478
|
-
title: epic.title,
|
|
479
|
-
goal: epic.goal,
|
|
480
|
-
stories: epic.stories,
|
|
481
|
-
status: epic.status,
|
|
482
|
-
});
|
|
483
|
-
const storyLines = epic.stories.map((s) => `- ${s}`).join("\n");
|
|
484
|
-
return `---\n${frontmatter}---\n\n# ${epic.id}: ${epic.title}\n\n**Ziel:** ${epic.goal || "(noch nicht definiert)"}\n\n## Stories\n\n${storyLines || "(noch keine Stories)"}\n`;
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
function parseEpicFile(filePath: string): Epic | null {
|
|
488
|
-
try {
|
|
489
|
-
const content = fs.readFileSync(filePath, "utf8");
|
|
490
|
-
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
491
|
-
if (!match) return null;
|
|
492
|
-
const data = yaml.load(match[1]) as any;
|
|
493
|
-
return {
|
|
494
|
-
id: data.id ?? "",
|
|
495
|
-
title: data.title ?? "",
|
|
496
|
-
goal: data.goal ?? "",
|
|
497
|
-
stories: data.stories ?? [],
|
|
498
|
-
status: data.status ?? "active",
|
|
499
|
-
};
|
|
500
|
-
} catch {
|
|
501
|
-
return null;
|
|
502
|
-
}
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
export function createEpic(epicsDir: string, title: string, goal?: string): Epic {
|
|
506
|
-
fs.mkdirSync(epicsDir, { recursive: true });
|
|
507
|
-
const id = nextEpicId(epicsDir);
|
|
508
|
-
const epic: Epic = {
|
|
509
|
-
id,
|
|
510
|
-
title,
|
|
511
|
-
goal: goal ?? "",
|
|
512
|
-
stories: [],
|
|
513
|
-
status: "active",
|
|
514
|
-
};
|
|
515
|
-
fs.writeFileSync(path.join(epicsDir, `${id}.md`), epicToMarkdown(epic), "utf8");
|
|
516
|
-
return epic;
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
export function listEpics(epicsDir: string): Epic[] {
|
|
520
|
-
if (!fs.existsSync(epicsDir)) return [];
|
|
521
|
-
return fs.readdirSync(epicsDir)
|
|
522
|
-
.filter((f) => f.match(/^EPC-\d+\.md$/))
|
|
523
|
-
.map((f) => parseEpicFile(path.join(epicsDir, f)))
|
|
524
|
-
.filter((e): e is Epic => e !== null)
|
|
525
|
-
.sort((a, b) => {
|
|
526
|
-
const na = parseInt(a.id.replace("EPC-", ""), 10);
|
|
527
|
-
const nb = parseInt(b.id.replace("EPC-", ""), 10);
|
|
528
|
-
return na - nb;
|
|
529
|
-
});
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
export function getEpic(epicsDir: string, id: string): Epic | null {
|
|
533
|
-
const filePath = path.join(epicsDir, `${id}.md`);
|
|
534
|
-
if (!fs.existsSync(filePath)) return null;
|
|
535
|
-
return parseEpicFile(filePath);
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
export function addStoryToEpic(epicsDir: string, epicId: string, storyId: string): void {
|
|
539
|
-
const epic = getEpic(epicsDir, epicId);
|
|
540
|
-
if (!epic) throw new Error(`Epic ${epicId} nicht gefunden`);
|
|
541
|
-
if (!epic.stories.includes(storyId)) {
|
|
542
|
-
epic.stories.push(storyId);
|
|
543
|
-
fs.writeFileSync(path.join(epicsDir, `${epicId}.md`), epicToMarkdown(epic), "utf8");
|
|
544
|
-
}
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
export function formatEpicCard(epic: Epic, stories?: { id: string; title: string; points: number | null; status: string }[]): string {
|
|
548
|
-
const lines: string[] = [];
|
|
549
|
-
lines.push(`## ${epic.id}: ${epic.title} [${epic.status}]`);
|
|
550
|
-
if (epic.goal) lines.push(`**Ziel:** ${epic.goal}`);
|
|
551
|
-
lines.push(`**Stories:** ${epic.stories.length}`);
|
|
552
|
-
if (stories && stories.length > 0) {
|
|
553
|
-
const total = stories.reduce((sum, s) => sum + (s.points ?? 0), 0);
|
|
554
|
-
lines.push(`**Punkte:** ${total}`);
|
|
555
|
-
lines.push("");
|
|
556
|
-
for (const s of stories) {
|
|
557
|
-
const pts = s.points !== null ? ` [${s.points}pt]` : " [?pt]";
|
|
558
|
-
lines.push(` - ${s.id}${pts} — ${s.title} [${s.status}]`);
|
|
559
|
-
}
|
|
560
|
-
} else if (epic.stories.length > 0) {
|
|
561
|
-
for (const id of epic.stories) lines.push(` - ${id}`);
|
|
562
|
-
}
|
|
563
|
-
return lines.join("\n");
|
|
564
|
-
}
|
|
565
|
-
```
|
|
566
|
-
|
|
567
|
-
- [ ] **Step 4: Run test to verify it passes**
|
|
568
|
-
|
|
569
|
-
```bash
|
|
570
|
-
node --import tsx/esm --test test/epics.test.ts
|
|
571
|
-
```
|
|
572
|
-
Expected: 7 passing
|
|
573
|
-
|
|
574
|
-
- [ ] **Step 5: Wire `fh epic` in cli.ts**
|
|
575
|
-
|
|
576
|
-
Add import:
|
|
577
|
-
```typescript
|
|
578
|
-
import { createEpic, listEpics, getEpic, addStoryToEpic, formatEpicCard } from "./epics.ts";
|
|
579
|
-
```
|
|
580
|
-
|
|
581
|
-
Add handler:
|
|
582
|
-
```typescript
|
|
583
|
-
} else if (command === "epic") {
|
|
584
|
-
const epicsDir = path.join(forgehiveDir, "memory", "epics");
|
|
585
|
-
const storiesDir = path.join(forgehiveDir, "memory", "stories");
|
|
586
|
-
if (subcommand === "create") {
|
|
587
|
-
const title = rest.filter((r) => !r.startsWith("--")).join(" ");
|
|
588
|
-
const goalArg = rest.includes("--goal") ? rest[rest.indexOf("--goal") + 1] : undefined;
|
|
589
|
-
if (!title) { console.error("Usage: fh epic create <titel> [--goal <ziel>]"); process.exit(1); }
|
|
590
|
-
const epic = createEpic(epicsDir, title, goalArg);
|
|
591
|
-
console.log(`✔ ${epic.id} erstellt: ${path.join(epicsDir, epic.id + ".md")}`);
|
|
592
|
-
} else if (subcommand === "list") {
|
|
593
|
-
const epics = listEpics(epicsDir);
|
|
594
|
-
if (epics.length === 0) { console.log("Keine Epics gefunden."); }
|
|
595
|
-
else {
|
|
596
|
-
for (const e of epics)
|
|
597
|
-
console.log(` ${e.id} — ${e.title} [${e.status}] (${e.stories.length} Stories)`);
|
|
598
|
-
}
|
|
599
|
-
} else if (subcommand === "show") {
|
|
600
|
-
const id = rest[0];
|
|
601
|
-
if (!id) { console.error("Usage: fh epic show <EPC-N>"); process.exit(1); }
|
|
602
|
-
const epic = getEpic(epicsDir, id);
|
|
603
|
-
if (!epic) { console.error(`Epic ${id} nicht gefunden`); process.exit(1); }
|
|
604
|
-
const stories = listStories(storiesDir).filter((s) => s.epicId === id);
|
|
605
|
-
console.log(formatEpicCard(epic, stories));
|
|
606
|
-
} else {
|
|
607
|
-
console.error("Verfügbar: fh epic create | list | show");
|
|
608
|
-
}
|
|
609
|
-
```
|
|
610
|
-
|
|
611
|
-
- [ ] **Step 6: Commit**
|
|
612
|
-
|
|
613
|
-
```bash
|
|
614
|
-
git add src/epics.ts test/epics.test.ts src/cli.ts
|
|
615
|
-
git commit -m "feat: add fh epic command with Epic hierarchy management"
|
|
616
|
-
```
|
|
617
|
-
|
|
618
|
-
---
|
|
619
|
-
|
|
620
|
-
## Task 3: Velocity Tracking (`src/velocity.ts`)
|
|
621
|
-
|
|
622
|
-
**Files:**
|
|
623
|
-
- Create: `src/velocity.ts`
|
|
624
|
-
- Create: `test/velocity.test.ts`
|
|
625
|
-
- Modify: `src/cli.ts`
|
|
626
|
-
|
|
627
|
-
- [ ] **Step 1: Write the failing test**
|
|
628
|
-
|
|
629
|
-
```typescript
|
|
630
|
-
// test/velocity.test.ts
|
|
631
|
-
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
632
|
-
import assert from "node:assert/strict";
|
|
633
|
-
import fs from "node:fs";
|
|
634
|
-
import path from "node:path";
|
|
635
|
-
import os from "node:os";
|
|
636
|
-
import {
|
|
637
|
-
recordVelocity,
|
|
638
|
-
getVelocityHistory,
|
|
639
|
-
getRollingAverage,
|
|
640
|
-
formatVelocityReport,
|
|
641
|
-
} from "../src/velocity.ts";
|
|
642
|
-
|
|
643
|
-
describe("recordVelocity()", () => {
|
|
644
|
-
let tmpDir: string;
|
|
645
|
-
let velocityFile: string;
|
|
646
|
-
beforeEach(() => {
|
|
647
|
-
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "fh-velocity-"));
|
|
648
|
-
velocityFile = path.join(tmpDir, "velocity.md");
|
|
649
|
-
});
|
|
650
|
-
afterEach(() => fs.rmSync(tmpDir, { recursive: true, force: true }));
|
|
651
|
-
|
|
652
|
-
it("creates velocity file on first record", () => {
|
|
653
|
-
recordVelocity(velocityFile, 1, 13, 10);
|
|
654
|
-
assert.ok(fs.existsSync(velocityFile));
|
|
655
|
-
});
|
|
656
|
-
|
|
657
|
-
it("appends subsequent records", () => {
|
|
658
|
-
recordVelocity(velocityFile, 1, 13, 10);
|
|
659
|
-
recordVelocity(velocityFile, 2, 15, 13);
|
|
660
|
-
const history = getVelocityHistory(velocityFile);
|
|
661
|
-
assert.equal(history.length, 2);
|
|
662
|
-
});
|
|
663
|
-
});
|
|
664
|
-
|
|
665
|
-
describe("getVelocityHistory()", () => {
|
|
666
|
-
let tmpDir: string;
|
|
667
|
-
let velocityFile: string;
|
|
668
|
-
beforeEach(() => {
|
|
669
|
-
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "fh-velocity-"));
|
|
670
|
-
velocityFile = path.join(tmpDir, "velocity.md");
|
|
671
|
-
});
|
|
672
|
-
afterEach(() => fs.rmSync(tmpDir, { recursive: true, force: true }));
|
|
673
|
-
|
|
674
|
-
it("returns empty array when file does not exist", () => {
|
|
675
|
-
const history = getVelocityHistory(velocityFile);
|
|
676
|
-
assert.equal(history.length, 0);
|
|
677
|
-
});
|
|
678
|
-
|
|
679
|
-
it("returns correct committed and delivered values", () => {
|
|
680
|
-
recordVelocity(velocityFile, 1, 13, 10);
|
|
681
|
-
const history = getVelocityHistory(velocityFile);
|
|
682
|
-
assert.equal(history[0].committed, 13);
|
|
683
|
-
assert.equal(history[0].delivered, 10);
|
|
684
|
-
assert.equal(history[0].sprint, 1);
|
|
685
|
-
});
|
|
686
|
-
});
|
|
687
|
-
|
|
688
|
-
describe("getRollingAverage()", () => {
|
|
689
|
-
it("returns average of last N sprints", () => {
|
|
690
|
-
const history = [
|
|
691
|
-
{ sprint: 1, date: "2026-01-01", committed: 10, delivered: 8 },
|
|
692
|
-
{ sprint: 2, date: "2026-01-15", committed: 12, delivered: 10 },
|
|
693
|
-
{ sprint: 3, date: "2026-02-01", committed: 14, delivered: 12 },
|
|
694
|
-
];
|
|
695
|
-
const avg = getRollingAverage(history, 2);
|
|
696
|
-
assert.equal(avg, 11); // (10 + 12) / 2
|
|
697
|
-
});
|
|
698
|
-
|
|
699
|
-
it("returns 0 for empty history", () => {
|
|
700
|
-
assert.equal(getRollingAverage([]), 0);
|
|
701
|
-
});
|
|
702
|
-
});
|
|
703
|
-
|
|
704
|
-
describe("formatVelocityReport()", () => {
|
|
705
|
-
it("produces markdown with sprint table", () => {
|
|
706
|
-
const history = [
|
|
707
|
-
{ sprint: 1, date: "2026-01-01", committed: 10, delivered: 8 },
|
|
708
|
-
];
|
|
709
|
-
const md = formatVelocityReport(history);
|
|
710
|
-
assert.ok(md.includes("Sprint 1"));
|
|
711
|
-
assert.ok(md.includes("10"));
|
|
712
|
-
assert.ok(md.includes("8"));
|
|
713
|
-
});
|
|
714
|
-
});
|
|
715
|
-
```
|
|
716
|
-
|
|
717
|
-
- [ ] **Step 2: Run test to verify it fails**
|
|
718
|
-
|
|
719
|
-
```bash
|
|
720
|
-
node --import tsx/esm --test test/velocity.test.ts
|
|
721
|
-
```
|
|
722
|
-
Expected: FAIL
|
|
723
|
-
|
|
724
|
-
- [ ] **Step 3: Write the implementation**
|
|
725
|
-
|
|
726
|
-
```typescript
|
|
727
|
-
// src/velocity.ts
|
|
728
|
-
import fs from "node:fs";
|
|
729
|
-
import path from "node:path";
|
|
730
|
-
|
|
731
|
-
export interface SprintVelocity {
|
|
732
|
-
sprint: number;
|
|
733
|
-
date: string;
|
|
734
|
-
committed: number;
|
|
735
|
-
delivered: number;
|
|
736
|
-
}
|
|
737
|
-
|
|
738
|
-
const HEADER = "# Sprint Velocity\n\n| Sprint | Datum | Committed | Delivered | Rate |\n|---|---|---|---|---|\n";
|
|
739
|
-
|
|
740
|
-
export function recordVelocity(
|
|
741
|
-
velocityFile: string,
|
|
742
|
-
sprint: number,
|
|
743
|
-
committed: number,
|
|
744
|
-
delivered: number
|
|
745
|
-
): void {
|
|
746
|
-
const date = new Date().toISOString().slice(0, 10);
|
|
747
|
-
const rate = committed > 0 ? Math.round((delivered / committed) * 100) : 0;
|
|
748
|
-
const row = `| Sprint ${sprint} | ${date} | ${committed} | ${delivered} | ${rate}% |\n`;
|
|
749
|
-
|
|
750
|
-
if (!fs.existsSync(velocityFile)) {
|
|
751
|
-
fs.mkdirSync(path.dirname(velocityFile), { recursive: true });
|
|
752
|
-
fs.writeFileSync(velocityFile, HEADER + row, "utf8");
|
|
753
|
-
} else {
|
|
754
|
-
fs.appendFileSync(velocityFile, row, "utf8");
|
|
755
|
-
}
|
|
756
|
-
}
|
|
757
|
-
|
|
758
|
-
export function getVelocityHistory(velocityFile: string): SprintVelocity[] {
|
|
759
|
-
if (!fs.existsSync(velocityFile)) return [];
|
|
760
|
-
const content = fs.readFileSync(velocityFile, "utf8");
|
|
761
|
-
const rows = content
|
|
762
|
-
.split("\n")
|
|
763
|
-
.filter((l) => l.startsWith("| Sprint "));
|
|
764
|
-
|
|
765
|
-
return rows.map((row) => {
|
|
766
|
-
const cells = row.split("|").map((c) => c.trim()).filter(Boolean);
|
|
767
|
-
const sprintMatch = cells[0]?.match(/Sprint (\d+)/);
|
|
768
|
-
return {
|
|
769
|
-
sprint: sprintMatch ? parseInt(sprintMatch[1], 10) : 0,
|
|
770
|
-
date: cells[1] ?? "",
|
|
771
|
-
committed: parseInt(cells[2] ?? "0", 10),
|
|
772
|
-
delivered: parseInt(cells[3] ?? "0", 10),
|
|
773
|
-
};
|
|
774
|
-
}).filter((r) => r.sprint > 0);
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
export function getRollingAverage(history: SprintVelocity[], window = 3): number {
|
|
778
|
-
if (history.length === 0) return 0;
|
|
779
|
-
const recent = history.slice(-window);
|
|
780
|
-
const sum = recent.reduce((acc, s) => acc + s.delivered, 0);
|
|
781
|
-
return Math.round(sum / recent.length);
|
|
782
|
-
}
|
|
783
|
-
|
|
784
|
-
export function formatVelocityReport(history: SprintVelocity[]): string {
|
|
785
|
-
if (history.length === 0) return "Noch keine Velocity-Daten vorhanden.\n\nStarte mit: `fh velocity record <sprint> --committed N --delivered N`";
|
|
786
|
-
|
|
787
|
-
const avg3 = getRollingAverage(history, 3);
|
|
788
|
-
const lines: string[] = [];
|
|
789
|
-
lines.push("# Sprint Velocity");
|
|
790
|
-
lines.push("");
|
|
791
|
-
lines.push(`**Rolling Average (letzte 3 Sprints):** ${avg3} Punkte`);
|
|
792
|
-
lines.push("");
|
|
793
|
-
lines.push("| Sprint | Datum | Committed | Delivered | Rate |");
|
|
794
|
-
lines.push("|---|---|---|---|---|");
|
|
795
|
-
for (const s of history) {
|
|
796
|
-
const rate = s.committed > 0 ? Math.round((s.delivered / s.committed) * 100) : 0;
|
|
797
|
-
lines.push(`| Sprint ${s.sprint} | ${s.date} | ${s.committed} | ${s.delivered} | ${rate}% |`);
|
|
798
|
-
}
|
|
799
|
-
lines.push("");
|
|
800
|
-
lines.push(`**Empfehlung für nächsten Sprint:** ~${avg3} Punkte einplanen`);
|
|
801
|
-
return lines.join("\n");
|
|
802
|
-
}
|
|
803
|
-
```
|
|
804
|
-
|
|
805
|
-
- [ ] **Step 4: Run test to verify it passes**
|
|
806
|
-
|
|
807
|
-
```bash
|
|
808
|
-
node --import tsx/esm --test test/velocity.test.ts
|
|
809
|
-
```
|
|
810
|
-
Expected: 8 passing
|
|
811
|
-
|
|
812
|
-
- [ ] **Step 5: Wire `fh velocity` in cli.ts**
|
|
813
|
-
|
|
814
|
-
Add import:
|
|
815
|
-
```typescript
|
|
816
|
-
import { recordVelocity, getVelocityHistory, getRollingAverage, formatVelocityReport } from "./velocity.ts";
|
|
817
|
-
```
|
|
818
|
-
|
|
819
|
-
Add handler:
|
|
820
|
-
```typescript
|
|
821
|
-
} else if (command === "velocity") {
|
|
822
|
-
const velocityFile = path.join(forgehiveDir, "memory", "velocity.md");
|
|
823
|
-
if (subcommand === "record") {
|
|
824
|
-
const sprintNum = parseInt(rest[0] ?? "0", 10);
|
|
825
|
-
const committed = rest.includes("--committed") ? parseInt(rest[rest.indexOf("--committed") + 1], 10) : NaN;
|
|
826
|
-
const delivered = rest.includes("--delivered") ? parseInt(rest[rest.indexOf("--delivered") + 1], 10) : NaN;
|
|
827
|
-
if (!sprintNum || isNaN(committed) || isNaN(delivered)) {
|
|
828
|
-
console.error("Usage: fh velocity record <sprint-num> --committed N --delivered N");
|
|
829
|
-
process.exit(1);
|
|
830
|
-
}
|
|
831
|
-
recordVelocity(velocityFile, sprintNum, committed, delivered);
|
|
832
|
-
const avg = getRollingAverage(getVelocityHistory(velocityFile));
|
|
833
|
-
console.log(`✔ Sprint ${sprintNum} gespeichert. Rolling Average: ${avg} Punkte`);
|
|
834
|
-
} else {
|
|
835
|
-
const history = getVelocityHistory(velocityFile);
|
|
836
|
-
console.log(formatVelocityReport(history));
|
|
837
|
-
}
|
|
838
|
-
```
|
|
839
|
-
|
|
840
|
-
- [ ] **Step 6: Commit**
|
|
841
|
-
|
|
842
|
-
```bash
|
|
843
|
-
git add src/velocity.ts test/velocity.test.ts src/cli.ts
|
|
844
|
-
git commit -m "feat: add fh velocity command for sprint velocity tracking"
|
|
845
|
-
```
|
|
846
|
-
|
|
847
|
-
---
|
|
848
|
-
|
|
849
|
-
## Task 4: Enhance `/fh-sprint` + version bump
|
|
850
|
-
|
|
851
|
-
**Files:**
|
|
852
|
-
- Modify: `forgehive/commands/fh-sprint.md`
|
|
853
|
-
- Modify: `src/cli.ts` (version → 0.7.1)
|
|
854
|
-
- Modify: `package.json` (version → 0.7.1)
|
|
855
|
-
|
|
856
|
-
- [ ] **Step 1: Replace fh-sprint.md with enhanced version**
|
|
857
|
-
|
|
858
|
-
Overwrite `forgehive/commands/fh-sprint.md` entirely with:
|
|
859
|
-
|
|
860
|
-
```markdown
|
|
861
|
-
You are running a Sprint Planning session using the ForgeHive workflow.
|
|
862
|
-
|
|
863
|
-
## Sprint Planning Protocol
|
|
864
|
-
|
|
865
|
-
### Step 1: Load context
|
|
866
|
-
|
|
867
|
-
1. Read `.forgehive/capabilities.yaml` — understand the tech stack and constraints
|
|
868
|
-
2. Read `.forgehive/memory/MEMORY.md` and all linked memory files — load project context
|
|
869
|
-
3. Check if `.forgehive/memory/sprint.md` exists — if so, show the last sprint summary first
|
|
870
|
-
4. Check if `.forgehive/memory/velocity.md` exists — if so, show rolling average as capacity hint
|
|
871
|
-
5. Run `fh scan --check` to verify the codebase snapshot is current
|
|
872
|
-
|
|
873
|
-
### Step 2: Collect backlog items
|
|
874
|
-
|
|
875
|
-
Ask the user: **"Welche Items kommen in den Sprint? Liste sie auf — oder soll ich Backlog-Stories laden?"**
|
|
876
|
-
|
|
877
|
-
**If stories exist** in `.forgehive/memory/stories/` with status `backlog`:
|
|
878
|
-
Run `fh story list` to show available stories. Ask: **"Welche dieser Stories kommen in den Sprint?"**
|
|
879
|
-
|
|
880
|
-
**If Linear MCP is available** (check if `.mcp.json` contains `linear`):
|
|
881
|
-
Use the Linear MCP tool to fetch open issues:
|
|
882
|
-
```
|
|
883
|
-
mcp__linear__list_issues({ state: "backlog", limit: 30 })
|
|
884
|
-
```
|
|
885
|
-
Show the fetched issues and ask: **"Welche davon kommen in den Sprint?"**
|
|
886
|
-
|
|
887
|
-
**If GitHub MCP is available** (check if `.mcp.json` contains `github`):
|
|
888
|
-
```bash
|
|
889
|
-
gh issue list --state open --label "sprint-candidate" --limit 20
|
|
890
|
-
```
|
|
891
|
-
|
|
892
|
-
**If no stories/MCP:**
|
|
893
|
-
Accept free-text input — one item per line.
|
|
894
|
-
|
|
895
|
-
### Step 3: Clarify and refine
|
|
896
|
-
|
|
897
|
-
For each item, ask one clarifying question if needed:
|
|
898
|
-
- Is this a feature, bug fix, or chore?
|
|
899
|
-
- Is there a hard dependency on another item?
|
|
900
|
-
- Any known technical risks?
|
|
901
|
-
|
|
902
|
-
Do NOT ask more than one question per item. If the item is clear, skip this step.
|
|
903
|
-
|
|
904
|
-
### Step 4: Estimate with Fibonacci Points
|
|
905
|
-
|
|
906
|
-
Estimate each item using Fibonacci story points:
|
|
907
|
-
|
|
908
|
-
| Points | Meaning | Typical scope |
|
|
909
|
-
|---|---|---|
|
|
910
|
-
| 1 | Trivial | Config change, copy fix, 1-line fix |
|
|
911
|
-
| 2 | Small | Simple isolated change |
|
|
912
|
-
| 3 | Medium-small | Small feature, isolated fix |
|
|
913
|
-
| 5 | Medium | Feature with tests, moderate complexity |
|
|
914
|
-
| 8 | Large | Complex feature, multiple files |
|
|
915
|
-
| 13 | Extra-large | Should be broken down |
|
|
916
|
-
|
|
917
|
-
**T-Shirt aliases:** XS=1, S=2, M=5, L=8, XL=13
|
|
918
|
-
|
|
919
|
-
Flag any 13-point items: **"Dieses Item ist zu groß für einen Sprint — soll ich es aufteilen?"**
|
|
920
|
-
|
|
921
|
-
If items were loaded from `.forgehive/memory/stories/`, update their points:
|
|
922
|
-
```bash
|
|
923
|
-
fh story <US-N> --points <N>
|
|
924
|
-
```
|
|
925
|
-
|
|
926
|
-
### Step 5: Prioritize
|
|
927
|
-
|
|
928
|
-
Sort items into three buckets:
|
|
929
|
-
|
|
930
|
-
**Must (Sprint-Ziel)** — delivers the sprint goal, blocks other work, or is overdue
|
|
931
|
-
**Should (Best Effort)** — important but not blocking
|
|
932
|
-
**Could (Nice to Have)** — do if capacity allows
|
|
933
|
-
|
|
934
|
-
Suggest a sprint goal in one sentence based on the Must items.
|
|
935
|
-
|
|
936
|
-
### Step 6: Check capacity
|
|
937
|
-
|
|
938
|
-
Show velocity hint if available:
|
|
939
|
-
```bash
|
|
940
|
-
fh velocity show
|
|
941
|
-
```
|
|
942
|
-
|
|
943
|
-
Ask: **"Wie viele Punkte habt ihr im Sprint?"**
|
|
944
|
-
|
|
945
|
-
Default: rolling average from velocity history, or 20 points for a 2-week sprint with one developer.
|
|
946
|
-
|
|
947
|
-
Calculate if Must + Should items fit. If they don't, move the lowest-priority Should items to Could.
|
|
948
|
-
|
|
949
|
-
Show a capacity summary:
|
|
950
|
-
```
|
|
951
|
-
Sprint-Kapazität: 20 Punkte
|
|
952
|
-
Must: [sum] Punkte
|
|
953
|
-
Should: [sum] Punkte
|
|
954
|
-
Could: [sum] Punkte
|
|
955
|
-
─────────────────────
|
|
956
|
-
Geplant: [must+should] / 20 Punkte
|
|
957
|
-
```
|
|
958
|
-
|
|
959
|
-
### Step 7: Output the sprint plan
|
|
960
|
-
|
|
961
|
-
Write the sprint plan to `.forgehive/memory/sprint.md` in this format:
|
|
962
|
-
|
|
963
|
-
```markdown
|
|
964
|
-
# Sprint [N] — [Datum]
|
|
965
|
-
|
|
966
|
-
**Ziel:** [one sentence sprint goal]
|
|
967
|
-
|
|
968
|
-
**Kapazität:** [X] Punkte
|
|
969
|
-
|
|
970
|
-
## Must
|
|
971
|
-
- [ ] [ID] [Item] ([points]pt) — [one-line description]
|
|
972
|
-
|
|
973
|
-
## Should
|
|
974
|
-
- [ ] [ID] [Item] ([points]pt) — [one-line description]
|
|
975
|
-
|
|
976
|
-
## Could
|
|
977
|
-
- [ ] [ID] [Item] ([points]pt) — [one-line description]
|
|
978
|
-
|
|
979
|
-
## Offen / Blocked
|
|
980
|
-
- [any blocked items with reason]
|
|
981
|
-
|
|
982
|
-
---
|
|
983
|
-
*Erstellt mit fh sprint — [timestamp]*
|
|
984
|
-
```
|
|
985
|
-
|
|
986
|
-
Confirm with the user: **"Sprint Plan gespeichert. Soll ich für jedes Must-Item direkt einen Branch anlegen?"**
|
|
987
|
-
|
|
988
|
-
If yes: create branches following `feat/<slug>`, `fix/<slug>`, `chore/<slug>`.
|
|
989
|
-
|
|
990
|
-
### Step 8: Update project memory
|
|
991
|
-
|
|
992
|
-
Append the sprint goal to `.forgehive/memory/project.md` under a `## Aktueller Sprint` section.
|
|
993
|
-
|
|
994
|
-
### Step 9: Record velocity (at sprint end)
|
|
995
|
-
|
|
996
|
-
When the user runs `/fh-sprint` and mentions "Sprint ist fertig" or "Sprint abgeschlossen":
|
|
997
|
-
|
|
998
|
-
1. Ask: **"Wie viele Punkte habt ihr tatsächlich geliefert?"**
|
|
999
|
-
2. Read the committed points from `sprint.md`
|
|
1000
|
-
3. Record velocity:
|
|
1001
|
-
```bash
|
|
1002
|
-
fh velocity record <N> --committed <committed> --delivered <delivered>
|
|
1003
|
-
```
|
|
1004
|
-
4. Show updated velocity report:
|
|
1005
|
-
```bash
|
|
1006
|
-
fh velocity show
|
|
1007
|
-
```
|
|
1008
|
-
```
|
|
1009
|
-
|
|
1010
|
-
- [ ] **Step 2: Bump version to 0.7.1**
|
|
1011
|
-
|
|
1012
|
-
In `src/cli.ts`, change `console.log("0.7.0")` to `console.log("0.7.1")`.
|
|
1013
|
-
In `package.json`, change `"version": "0.7.0"` to `"version": "0.7.1"`.
|
|
1014
|
-
|
|
1015
|
-
- [ ] **Step 3: Commit**
|
|
1016
|
-
|
|
1017
|
-
```bash
|
|
1018
|
-
git add forgehive/commands/fh-sprint.md src/cli.ts package.json
|
|
1019
|
-
git commit -m "feat: enhance /fh-sprint with story points, velocity, epic-aware backlog"
|
|
1020
|
-
```
|
|
1021
|
-
|
|
1022
|
-
---
|
|
1023
|
-
|
|
1024
|
-
## Task 5: Full test run, build, publish 0.7.1
|
|
1025
|
-
|
|
1026
|
-
- [ ] **Step 1: Run all tests**
|
|
1027
|
-
|
|
1028
|
-
```bash
|
|
1029
|
-
npm test
|
|
1030
|
-
```
|
|
1031
|
-
Expected: all passing (target: ~270+ tests)
|
|
1032
|
-
|
|
1033
|
-
- [ ] **Step 2: Typecheck**
|
|
1034
|
-
|
|
1035
|
-
```bash
|
|
1036
|
-
npm run typecheck
|
|
1037
|
-
```
|
|
1038
|
-
|
|
1039
|
-
- [ ] **Step 3: Build**
|
|
1040
|
-
|
|
1041
|
-
```bash
|
|
1042
|
-
npm run build
|
|
1043
|
-
head -1 dist/cli.js # must be #!/usr/bin/env node
|
|
1044
|
-
```
|
|
1045
|
-
|
|
1046
|
-
- [ ] **Step 4: Publish**
|
|
1047
|
-
|
|
1048
|
-
```bash
|
|
1049
|
-
npm publish
|
|
1050
|
-
```
|
|
1051
|
-
|
|
1052
|
-
- [ ] **Step 5: Update memory**
|
|
1053
|
-
|
|
1054
|
-
Update `/home/stefan/.claude/projects/-home-stefan-projekte-framework-os/memory/project.md`:
|
|
1055
|
-
- Version: v0.7.1
|
|
1056
|
-
- New: Story Cards, Epic hierarchy, Velocity tracking
|
|
1057
|
-
- Updated: /fh-sprint with Fibonacci points + velocity integration
|
|
1058
|
-
```
|