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.
@@ -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
- ```