forgehive 0.7.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +416 -53
- package/forgehive/commands/fh-sprint.md +65 -31
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -2751,8 +2751,8 @@ var init_harness = __esm({
|
|
|
2751
2751
|
|
|
2752
2752
|
// src/cli.ts
|
|
2753
2753
|
init_js_yaml();
|
|
2754
|
-
import
|
|
2755
|
-
import
|
|
2754
|
+
import fs30 from "node:fs";
|
|
2755
|
+
import path31 from "node:path";
|
|
2756
2756
|
|
|
2757
2757
|
// src/scanner.ts
|
|
2758
2758
|
import fs from "node:fs";
|
|
@@ -6196,24 +6196,282 @@ function runBackgroundAgent(forgehiveDir2, issueUrl, agentId) {
|
|
|
6196
6196
|
};
|
|
6197
6197
|
}
|
|
6198
6198
|
|
|
6199
|
+
// src/stories.ts
|
|
6200
|
+
init_js_yaml();
|
|
6201
|
+
import fs27 from "node:fs";
|
|
6202
|
+
import path28 from "node:path";
|
|
6203
|
+
function nextStoryId(storiesDir) {
|
|
6204
|
+
if (!fs27.existsSync(storiesDir)) return "US-1";
|
|
6205
|
+
const existing = fs27.readdirSync(storiesDir).filter((f) => f.match(/^US-\d+\.md$/)).map((f) => parseInt(f.replace("US-", "").replace(".md", ""), 10)).filter((n) => !isNaN(n));
|
|
6206
|
+
const max = existing.length > 0 ? Math.max(...existing) : 0;
|
|
6207
|
+
return `US-${max + 1}`;
|
|
6208
|
+
}
|
|
6209
|
+
function storyToMarkdown(story) {
|
|
6210
|
+
const frontmatter = jsYaml.dump({
|
|
6211
|
+
id: story.id,
|
|
6212
|
+
title: story.title,
|
|
6213
|
+
asA: story.asA,
|
|
6214
|
+
iWant: story.iWant,
|
|
6215
|
+
soThat: story.soThat,
|
|
6216
|
+
points: story.points,
|
|
6217
|
+
epicId: story.epicId,
|
|
6218
|
+
status: story.status
|
|
6219
|
+
});
|
|
6220
|
+
const acLines = story.acceptanceCriteria.map((c) => `- [ ] ${c}`).join("\n");
|
|
6221
|
+
return `---
|
|
6222
|
+
${frontmatter}---
|
|
6223
|
+
|
|
6224
|
+
# ${story.id}: ${story.title}
|
|
6225
|
+
|
|
6226
|
+
## User Story
|
|
6227
|
+
|
|
6228
|
+
Als **${story.asA}** m\xF6chte ich **${story.iWant}**, damit **${story.soThat}**.
|
|
6229
|
+
|
|
6230
|
+
## Acceptance Criteria
|
|
6231
|
+
|
|
6232
|
+
${acLines || "- [ ] (noch nicht definiert)"}
|
|
6233
|
+
`;
|
|
6234
|
+
}
|
|
6235
|
+
function parseStoryFile(filePath) {
|
|
6236
|
+
try {
|
|
6237
|
+
const content = fs27.readFileSync(filePath, "utf8");
|
|
6238
|
+
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
6239
|
+
if (!match) return null;
|
|
6240
|
+
const data = jsYaml.load(match[1]);
|
|
6241
|
+
return {
|
|
6242
|
+
id: data.id ?? "",
|
|
6243
|
+
title: data.title ?? "",
|
|
6244
|
+
asA: data.asA ?? "",
|
|
6245
|
+
iWant: data.iWant ?? "",
|
|
6246
|
+
soThat: data.soThat ?? "",
|
|
6247
|
+
acceptanceCriteria: data.acceptanceCriteria ?? [],
|
|
6248
|
+
points: data.points ?? null,
|
|
6249
|
+
epicId: data.epicId ?? null,
|
|
6250
|
+
status: data.status ?? "backlog"
|
|
6251
|
+
};
|
|
6252
|
+
} catch {
|
|
6253
|
+
return null;
|
|
6254
|
+
}
|
|
6255
|
+
}
|
|
6256
|
+
function createStory(storiesDir, title, epicId) {
|
|
6257
|
+
fs27.mkdirSync(storiesDir, { recursive: true });
|
|
6258
|
+
const id = nextStoryId(storiesDir);
|
|
6259
|
+
const story = {
|
|
6260
|
+
id,
|
|
6261
|
+
title,
|
|
6262
|
+
asA: "",
|
|
6263
|
+
iWant: title,
|
|
6264
|
+
soThat: "",
|
|
6265
|
+
acceptanceCriteria: [],
|
|
6266
|
+
points: null,
|
|
6267
|
+
epicId: epicId ?? null,
|
|
6268
|
+
status: "backlog"
|
|
6269
|
+
};
|
|
6270
|
+
fs27.writeFileSync(path28.join(storiesDir, `${id}.md`), storyToMarkdown(story), "utf8");
|
|
6271
|
+
return story;
|
|
6272
|
+
}
|
|
6273
|
+
function listStories(storiesDir) {
|
|
6274
|
+
if (!fs27.existsSync(storiesDir)) return [];
|
|
6275
|
+
return fs27.readdirSync(storiesDir).filter((f) => f.match(/^US-\d+\.md$/)).map((f) => parseStoryFile(path28.join(storiesDir, f))).filter((s) => s !== null).sort((a, b) => {
|
|
6276
|
+
const na = parseInt(a.id.replace("US-", ""), 10);
|
|
6277
|
+
const nb = parseInt(b.id.replace("US-", ""), 10);
|
|
6278
|
+
return na - nb;
|
|
6279
|
+
});
|
|
6280
|
+
}
|
|
6281
|
+
function getStory(storiesDir, id) {
|
|
6282
|
+
const filePath = path28.join(storiesDir, `${id}.md`);
|
|
6283
|
+
if (!fs27.existsSync(filePath)) return null;
|
|
6284
|
+
return parseStoryFile(filePath);
|
|
6285
|
+
}
|
|
6286
|
+
function updateStoryPoints(storiesDir, id, points) {
|
|
6287
|
+
const story = getStory(storiesDir, id);
|
|
6288
|
+
if (!story) throw new Error(`Story ${id} nicht gefunden`);
|
|
6289
|
+
story.points = points;
|
|
6290
|
+
fs27.writeFileSync(path28.join(storiesDir, `${id}.md`), storyToMarkdown(story), "utf8");
|
|
6291
|
+
}
|
|
6292
|
+
function updateStoryStatus(storiesDir, id, status) {
|
|
6293
|
+
const story = getStory(storiesDir, id);
|
|
6294
|
+
if (!story) throw new Error(`Story ${id} nicht gefunden`);
|
|
6295
|
+
story.status = status;
|
|
6296
|
+
fs27.writeFileSync(path28.join(storiesDir, `${id}.md`), storyToMarkdown(story), "utf8");
|
|
6297
|
+
}
|
|
6298
|
+
function formatStoryCard(story) {
|
|
6299
|
+
const points = story.points !== null ? ` \xB7 ${story.points} Punkte` : "";
|
|
6300
|
+
const epic = story.epicId ? ` \xB7 ${story.epicId}` : "";
|
|
6301
|
+
const lines = [];
|
|
6302
|
+
lines.push(`## ${story.id}: ${story.title}${points}${epic}`);
|
|
6303
|
+
lines.push(`**Status:** ${story.status}`);
|
|
6304
|
+
if (story.asA || story.iWant)
|
|
6305
|
+
lines.push(`**Story:** Als ${story.asA || "Nutzer"} m\xF6chte ich ${story.iWant}${story.soThat ? `, damit ${story.soThat}` : ""}.`);
|
|
6306
|
+
if (story.acceptanceCriteria.length > 0) {
|
|
6307
|
+
lines.push("**Acceptance Criteria:**");
|
|
6308
|
+
for (const ac of story.acceptanceCriteria) lines.push(`- ${ac}`);
|
|
6309
|
+
}
|
|
6310
|
+
return lines.join("\n");
|
|
6311
|
+
}
|
|
6312
|
+
|
|
6313
|
+
// src/epics.ts
|
|
6314
|
+
init_js_yaml();
|
|
6315
|
+
import fs28 from "node:fs";
|
|
6316
|
+
import path29 from "node:path";
|
|
6317
|
+
function nextEpicId(epicsDir) {
|
|
6318
|
+
if (!fs28.existsSync(epicsDir)) return "EPC-1";
|
|
6319
|
+
const existing = fs28.readdirSync(epicsDir).filter((f) => f.match(/^EPC-\d+\.md$/)).map((f) => parseInt(f.replace("EPC-", "").replace(".md", ""), 10)).filter((n) => !isNaN(n));
|
|
6320
|
+
const max = existing.length > 0 ? Math.max(...existing) : 0;
|
|
6321
|
+
return `EPC-${max + 1}`;
|
|
6322
|
+
}
|
|
6323
|
+
function epicToMarkdown(epic) {
|
|
6324
|
+
const frontmatter = jsYaml.dump({
|
|
6325
|
+
id: epic.id,
|
|
6326
|
+
title: epic.title,
|
|
6327
|
+
goal: epic.goal,
|
|
6328
|
+
stories: epic.stories,
|
|
6329
|
+
status: epic.status
|
|
6330
|
+
});
|
|
6331
|
+
const storyLines = epic.stories.map((s) => `- ${s}`).join("\n");
|
|
6332
|
+
return `---
|
|
6333
|
+
${frontmatter}---
|
|
6334
|
+
|
|
6335
|
+
# ${epic.id}: ${epic.title}
|
|
6336
|
+
|
|
6337
|
+
**Ziel:** ${epic.goal || "(noch nicht definiert)"}
|
|
6338
|
+
|
|
6339
|
+
## Stories
|
|
6340
|
+
|
|
6341
|
+
${storyLines || "(noch keine Stories)"}
|
|
6342
|
+
`;
|
|
6343
|
+
}
|
|
6344
|
+
function parseEpicFile(filePath) {
|
|
6345
|
+
try {
|
|
6346
|
+
const content = fs28.readFileSync(filePath, "utf8");
|
|
6347
|
+
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
6348
|
+
if (!match) return null;
|
|
6349
|
+
const data = jsYaml.load(match[1]);
|
|
6350
|
+
return {
|
|
6351
|
+
id: data.id ?? "",
|
|
6352
|
+
title: data.title ?? "",
|
|
6353
|
+
goal: data.goal ?? "",
|
|
6354
|
+
stories: data.stories ?? [],
|
|
6355
|
+
status: data.status ?? "active"
|
|
6356
|
+
};
|
|
6357
|
+
} catch {
|
|
6358
|
+
return null;
|
|
6359
|
+
}
|
|
6360
|
+
}
|
|
6361
|
+
function createEpic(epicsDir, title, goal) {
|
|
6362
|
+
fs28.mkdirSync(epicsDir, { recursive: true });
|
|
6363
|
+
const id = nextEpicId(epicsDir);
|
|
6364
|
+
const epic = { id, title, goal: goal ?? "", stories: [], status: "active" };
|
|
6365
|
+
fs28.writeFileSync(path29.join(epicsDir, `${id}.md`), epicToMarkdown(epic), "utf8");
|
|
6366
|
+
return epic;
|
|
6367
|
+
}
|
|
6368
|
+
function listEpics(epicsDir) {
|
|
6369
|
+
if (!fs28.existsSync(epicsDir)) return [];
|
|
6370
|
+
return fs28.readdirSync(epicsDir).filter((f) => f.match(/^EPC-\d+\.md$/)).map((f) => parseEpicFile(path29.join(epicsDir, f))).filter((e) => e !== null).sort((a, b) => {
|
|
6371
|
+
const na = parseInt(a.id.replace("EPC-", ""), 10);
|
|
6372
|
+
const nb = parseInt(b.id.replace("EPC-", ""), 10);
|
|
6373
|
+
return na - nb;
|
|
6374
|
+
});
|
|
6375
|
+
}
|
|
6376
|
+
function getEpic(epicsDir, id) {
|
|
6377
|
+
const filePath = path29.join(epicsDir, `${id}.md`);
|
|
6378
|
+
if (!fs28.existsSync(filePath)) return null;
|
|
6379
|
+
return parseEpicFile(filePath);
|
|
6380
|
+
}
|
|
6381
|
+
function formatEpicCard(epic, stories) {
|
|
6382
|
+
const lines = [];
|
|
6383
|
+
lines.push(`## ${epic.id}: ${epic.title} [${epic.status}]`);
|
|
6384
|
+
if (epic.goal) lines.push(`**Ziel:** ${epic.goal}`);
|
|
6385
|
+
lines.push(`**Stories:** ${epic.stories.length}`);
|
|
6386
|
+
if (stories && stories.length > 0) {
|
|
6387
|
+
const total = stories.reduce((sum, s) => sum + (s.points ?? 0), 0);
|
|
6388
|
+
lines.push(`**Punkte:** ${total}`);
|
|
6389
|
+
lines.push("");
|
|
6390
|
+
for (const s of stories) {
|
|
6391
|
+
const pts = s.points !== null ? ` [${s.points}pt]` : " [?pt]";
|
|
6392
|
+
lines.push(` - ${s.id}${pts} \u2014 ${s.title} [${s.status}]`);
|
|
6393
|
+
}
|
|
6394
|
+
} else if (epic.stories.length > 0) {
|
|
6395
|
+
for (const id of epic.stories) lines.push(` - ${id}`);
|
|
6396
|
+
}
|
|
6397
|
+
return lines.join("\n");
|
|
6398
|
+
}
|
|
6399
|
+
|
|
6400
|
+
// src/velocity.ts
|
|
6401
|
+
import fs29 from "node:fs";
|
|
6402
|
+
import path30 from "node:path";
|
|
6403
|
+
var HEADER = "# Sprint Velocity\n\n| Sprint | Datum | Committed | Delivered | Rate |\n|---|---|---|---|---|\n";
|
|
6404
|
+
function recordVelocity(velocityFile, sprint, committed, delivered) {
|
|
6405
|
+
const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
6406
|
+
const rate = committed > 0 ? Math.round(delivered / committed * 100) : 0;
|
|
6407
|
+
const row = `| Sprint ${sprint} | ${date} | ${committed} | ${delivered} | ${rate}% |
|
|
6408
|
+
`;
|
|
6409
|
+
if (!fs29.existsSync(velocityFile)) {
|
|
6410
|
+
fs29.mkdirSync(path30.dirname(velocityFile), { recursive: true });
|
|
6411
|
+
fs29.writeFileSync(velocityFile, HEADER + row, "utf8");
|
|
6412
|
+
} else {
|
|
6413
|
+
fs29.appendFileSync(velocityFile, row, "utf8");
|
|
6414
|
+
}
|
|
6415
|
+
}
|
|
6416
|
+
function getVelocityHistory(velocityFile) {
|
|
6417
|
+
if (!fs29.existsSync(velocityFile)) return [];
|
|
6418
|
+
const content = fs29.readFileSync(velocityFile, "utf8");
|
|
6419
|
+
const rows = content.split("\n").filter((l) => l.startsWith("| Sprint "));
|
|
6420
|
+
return rows.map((row) => {
|
|
6421
|
+
const cells = row.split("|").map((c) => c.trim()).filter(Boolean);
|
|
6422
|
+
const sprintMatch = cells[0]?.match(/Sprint (\d+)/);
|
|
6423
|
+
return {
|
|
6424
|
+
sprint: sprintMatch ? parseInt(sprintMatch[1], 10) : 0,
|
|
6425
|
+
date: cells[1] ?? "",
|
|
6426
|
+
committed: parseInt(cells[2] ?? "0", 10),
|
|
6427
|
+
delivered: parseInt(cells[3] ?? "0", 10)
|
|
6428
|
+
};
|
|
6429
|
+
}).filter((r) => r.sprint > 0);
|
|
6430
|
+
}
|
|
6431
|
+
function getRollingAverage(history, window = 3) {
|
|
6432
|
+
if (history.length === 0) return 0;
|
|
6433
|
+
const recent = history.slice(-window);
|
|
6434
|
+
const sum = recent.reduce((acc, s) => acc + s.delivered, 0);
|
|
6435
|
+
return Math.round(sum / recent.length);
|
|
6436
|
+
}
|
|
6437
|
+
function formatVelocityReport(history) {
|
|
6438
|
+
if (history.length === 0)
|
|
6439
|
+
return "Noch keine Velocity-Daten vorhanden.\n\nStarte mit: `fh velocity record <sprint> --committed N --delivered N`";
|
|
6440
|
+
const avg3 = getRollingAverage(history, 3);
|
|
6441
|
+
const lines = [];
|
|
6442
|
+
lines.push("# Sprint Velocity");
|
|
6443
|
+
lines.push("");
|
|
6444
|
+
lines.push(`**Rolling Average (letzte 3 Sprints):** ${avg3} Punkte`);
|
|
6445
|
+
lines.push("");
|
|
6446
|
+
lines.push("| Sprint | Datum | Committed | Delivered | Rate |");
|
|
6447
|
+
lines.push("|---|---|---|---|---|");
|
|
6448
|
+
for (const s of history) {
|
|
6449
|
+
const rate = s.committed > 0 ? Math.round(s.delivered / s.committed * 100) : 0;
|
|
6450
|
+
lines.push(`| Sprint ${s.sprint} | ${s.date} | ${s.committed} | ${s.delivered} | ${rate}% |`);
|
|
6451
|
+
}
|
|
6452
|
+
lines.push("");
|
|
6453
|
+
lines.push(`**Empfehlung f\xFCr n\xE4chsten Sprint:** ~${avg3} Punkte einplanen`);
|
|
6454
|
+
return lines.join("\n");
|
|
6455
|
+
}
|
|
6456
|
+
|
|
6199
6457
|
// src/cli.ts
|
|
6200
6458
|
var [, , command, subcommand, ...rest] = process.argv;
|
|
6201
6459
|
var projectRoot = process.cwd();
|
|
6202
|
-
var forgehiveDir =
|
|
6460
|
+
var forgehiveDir = path31.join(projectRoot, ".forgehive");
|
|
6203
6461
|
if (command === "--version" || command === "-v") {
|
|
6204
|
-
console.log("0.7.
|
|
6462
|
+
console.log("0.7.1");
|
|
6205
6463
|
process.exit(0);
|
|
6206
6464
|
}
|
|
6207
6465
|
function loadClaudeMdBlock() {
|
|
6208
|
-
const templatePath =
|
|
6209
|
-
|
|
6466
|
+
const templatePath = path31.join(
|
|
6467
|
+
path31.dirname(new URL(import.meta.url).pathname),
|
|
6210
6468
|
"..",
|
|
6211
6469
|
"forgehive",
|
|
6212
6470
|
"templates",
|
|
6213
6471
|
"claude-md.block.md"
|
|
6214
6472
|
);
|
|
6215
|
-
if (!
|
|
6216
|
-
return
|
|
6473
|
+
if (!fs30.existsSync(templatePath)) return "## forgehive\n\nSee .forgehive/ for configuration.";
|
|
6474
|
+
return fs30.readFileSync(templatePath, "utf8");
|
|
6217
6475
|
}
|
|
6218
6476
|
if (command === "init") {
|
|
6219
6477
|
console.log("\u{1F50D} Analysiere Projekt...\n");
|
|
@@ -6227,9 +6485,9 @@ if (command === "init") {
|
|
|
6227
6485
|
const block = loadClaudeMdBlock();
|
|
6228
6486
|
writeForgehiveDir(projectRoot, scanResult, capMap, block);
|
|
6229
6487
|
const hash = computeHash(projectRoot);
|
|
6230
|
-
|
|
6231
|
-
const runtimeDir =
|
|
6232
|
-
|
|
6488
|
+
fs30.writeFileSync(path31.join(forgehiveDir, ".scan-hash"), hash, "utf8");
|
|
6489
|
+
const runtimeDir = path31.join(
|
|
6490
|
+
path31.dirname(new URL(import.meta.url).pathname),
|
|
6233
6491
|
"..",
|
|
6234
6492
|
"forgehive"
|
|
6235
6493
|
);
|
|
@@ -6261,7 +6519,7 @@ if (command === "init") {
|
|
|
6261
6519
|
process.exit(1);
|
|
6262
6520
|
}
|
|
6263
6521
|
} else if (command === "memory") {
|
|
6264
|
-
if (!
|
|
6522
|
+
if (!fs30.existsSync(forgehiveDir)) {
|
|
6265
6523
|
console.error("Fehler: .forgehive/ nicht gefunden \u2014 f\xFChre zuerst `fh init` aus");
|
|
6266
6524
|
process.exit(1);
|
|
6267
6525
|
}
|
|
@@ -6270,7 +6528,7 @@ if (command === "init") {
|
|
|
6270
6528
|
} else if (subcommand === "clean") {
|
|
6271
6529
|
cleanMemory(forgehiveDir);
|
|
6272
6530
|
} else if (subcommand === "export") {
|
|
6273
|
-
const outputPath = rest[0] ??
|
|
6531
|
+
const outputPath = rest[0] ?? path31.join(projectRoot, "forgehive-memory-export.md");
|
|
6274
6532
|
try {
|
|
6275
6533
|
exportMemory(forgehiveDir, outputPath);
|
|
6276
6534
|
} catch (err) {
|
|
@@ -6318,7 +6576,7 @@ Ausf\xFChren mit --remove um zu l\xF6schen: fh memory prune ${days} --remove`);
|
|
|
6318
6576
|
} else if (subcommand === "snapshot") {
|
|
6319
6577
|
const snapAction = rest[0];
|
|
6320
6578
|
if (snapAction === "export") {
|
|
6321
|
-
const outPath = rest[1] ??
|
|
6579
|
+
const outPath = rest[1] ?? path31.join(
|
|
6322
6580
|
projectRoot,
|
|
6323
6581
|
`forgehive-snapshot-${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}.json`
|
|
6324
6582
|
);
|
|
@@ -6358,11 +6616,11 @@ Ausf\xFChren mit --remove um zu l\xF6schen: fh memory prune ${days} --remove`);
|
|
|
6358
6616
|
process.exit(1);
|
|
6359
6617
|
}
|
|
6360
6618
|
} else if (command === "scan" && subcommand === "--update") {
|
|
6361
|
-
if (!
|
|
6619
|
+
if (!fs30.existsSync(forgehiveDir)) {
|
|
6362
6620
|
console.error("Fehler: .forgehive/ nicht gefunden \u2014 f\xFChre zuerst `fh init` aus");
|
|
6363
6621
|
process.exit(1);
|
|
6364
6622
|
}
|
|
6365
|
-
const savedHash =
|
|
6623
|
+
const savedHash = fs30.existsSync(path31.join(forgehiveDir, ".scan-hash")) ? fs30.readFileSync(path31.join(forgehiveDir, ".scan-hash"), "utf8").trim() : null;
|
|
6366
6624
|
const currentHash = computeHash(projectRoot);
|
|
6367
6625
|
if (savedHash === currentHash) {
|
|
6368
6626
|
console.log("\u2713 Keine \xC4nderungen erkannt \u2014 capabilities.yaml ist aktuell");
|
|
@@ -6370,7 +6628,7 @@ Ausf\xFChren mit --remove um zu l\xF6schen: fh memory prune ${days} --remove`);
|
|
|
6370
6628
|
}
|
|
6371
6629
|
console.log("\u{1F50D} \xC4nderungen erkannt \u2014 scanne erneut...\n");
|
|
6372
6630
|
const oldDoc = jsYaml.load(
|
|
6373
|
-
|
|
6631
|
+
fs30.readFileSync(path31.join(forgehiveDir, "capabilities.yaml"), "utf8")
|
|
6374
6632
|
);
|
|
6375
6633
|
const oldMap = { confirmed: oldDoc.capabilities.confirmed ?? [], inferred: [] };
|
|
6376
6634
|
const scanResult = scan(projectRoot);
|
|
@@ -6390,16 +6648,16 @@ Ausf\xFChren mit --remove um zu l\xF6schen: fh memory prune ${days} --remove`);
|
|
|
6390
6648
|
console.log();
|
|
6391
6649
|
const block = loadClaudeMdBlock();
|
|
6392
6650
|
writeForgehiveDir(projectRoot, scanResult, newMap, block);
|
|
6393
|
-
|
|
6651
|
+
fs30.writeFileSync(path31.join(forgehiveDir, ".scan-hash"), currentHash, "utf8");
|
|
6394
6652
|
console.log("\u2713 scan-result.yaml und capabilities.yaml aktualisiert");
|
|
6395
6653
|
console.log(" F\xFChre `fh confirm` aus um die \xC4nderungen zu best\xE4tigen");
|
|
6396
6654
|
}
|
|
6397
6655
|
} else if (command === "scan" && subcommand === "--check") {
|
|
6398
|
-
if (!
|
|
6656
|
+
if (!fs30.existsSync(path31.join(forgehiveDir, ".scan-hash"))) {
|
|
6399
6657
|
console.log("Warnung: Kein Scan-Hash gefunden. F\xFChre `fh init` aus.");
|
|
6400
6658
|
process.exit(1);
|
|
6401
6659
|
}
|
|
6402
|
-
const saved =
|
|
6660
|
+
const saved = fs30.readFileSync(path31.join(forgehiveDir, ".scan-hash"), "utf8").trim();
|
|
6403
6661
|
const current = computeHash(projectRoot);
|
|
6404
6662
|
if (saved !== current) {
|
|
6405
6663
|
console.log("\u26A0 Codebase hat sich seit letztem Scan ge\xE4ndert.");
|
|
@@ -6408,7 +6666,7 @@ Ausf\xFChren mit --remove um zu l\xF6schen: fh memory prune ${days} --remove`);
|
|
|
6408
6666
|
}
|
|
6409
6667
|
console.log("\u2713 capabilities.yaml ist aktuell");
|
|
6410
6668
|
} else if (command === "skills") {
|
|
6411
|
-
if (!
|
|
6669
|
+
if (!fs30.existsSync(forgehiveDir)) {
|
|
6412
6670
|
console.error("Fehler: .forgehive/ nicht gefunden \u2014 f\xFChre zuerst `fh init` aus");
|
|
6413
6671
|
process.exit(1);
|
|
6414
6672
|
}
|
|
@@ -6444,7 +6702,7 @@ Ausf\xFChren mit --remove um zu l\xF6schen: fh memory prune ${days} --remove`);
|
|
|
6444
6702
|
process.exit(1);
|
|
6445
6703
|
}
|
|
6446
6704
|
} else if (command === "party") {
|
|
6447
|
-
if (!
|
|
6705
|
+
if (!fs30.existsSync(forgehiveDir)) {
|
|
6448
6706
|
console.error("Fehler: .forgehive/ nicht gefunden \u2014 f\xFChre zuerst `fh init` aus");
|
|
6449
6707
|
process.exit(1);
|
|
6450
6708
|
}
|
|
@@ -6565,7 +6823,7 @@ Ausf\xFChren mit --remove um zu l\xF6schen: fh memory prune ${days} --remove`);
|
|
|
6565
6823
|
}
|
|
6566
6824
|
}
|
|
6567
6825
|
} else if (command === "wire") {
|
|
6568
|
-
if (!
|
|
6826
|
+
if (!fs30.existsSync(forgehiveDir)) {
|
|
6569
6827
|
console.error("Fehler: .forgehive/ nicht gefunden \u2014 f\xFChre zuerst `fh init` aus");
|
|
6570
6828
|
process.exit(1);
|
|
6571
6829
|
}
|
|
@@ -6604,7 +6862,7 @@ N\xE4chster Schritt: Setze die erforderlichen Umgebungsvariablen und starte Clau
|
|
|
6604
6862
|
const limitIdx = allArgs.indexOf("--limit");
|
|
6605
6863
|
const alertIdx = allArgs.indexOf("--alert");
|
|
6606
6864
|
if (limitIdx !== -1) {
|
|
6607
|
-
if (!
|
|
6865
|
+
if (!fs30.existsSync(forgehiveDir)) {
|
|
6608
6866
|
console.error("Fehler: .forgehive/ nicht gefunden \u2014 f\xFChre zuerst `fh init` aus");
|
|
6609
6867
|
process.exit(1);
|
|
6610
6868
|
}
|
|
@@ -6630,14 +6888,14 @@ N\xE4chster Schritt: Setze die erforderlichen Umgebungsvariablen und starte Clau
|
|
|
6630
6888
|
}
|
|
6631
6889
|
const sessions = parseCostSessions(projectRoot);
|
|
6632
6890
|
console.log(formatCostReport(sessions, range));
|
|
6633
|
-
if (
|
|
6891
|
+
if (fs30.existsSync(forgehiveDir)) {
|
|
6634
6892
|
const total = sessions.reduce((s, x) => s + x.estimatedCostUsd, 0);
|
|
6635
6893
|
const status = checkSpendStatus(forgehiveDir, total);
|
|
6636
6894
|
if (status.message) console.log("\n" + status.message);
|
|
6637
6895
|
}
|
|
6638
6896
|
}
|
|
6639
6897
|
} else if (command === "watch") {
|
|
6640
|
-
if (!
|
|
6898
|
+
if (!fs30.existsSync(forgehiveDir)) {
|
|
6641
6899
|
console.error("Fehler: .forgehive/ nicht gefunden \u2014 f\xFChre zuerst `fh init` aus");
|
|
6642
6900
|
process.exit(1);
|
|
6643
6901
|
}
|
|
@@ -6653,7 +6911,7 @@ N\xE4chster Schritt: Setze die erforderlichen Umgebungsvariablen und starte Clau
|
|
|
6653
6911
|
process.exit(0);
|
|
6654
6912
|
});
|
|
6655
6913
|
} else if (command === "mcp") {
|
|
6656
|
-
if (!
|
|
6914
|
+
if (!fs30.existsSync(forgehiveDir)) {
|
|
6657
6915
|
console.error("Fehler: .forgehive/ nicht gefunden \u2014 f\xFChre zuerst `fh init` aus");
|
|
6658
6916
|
process.exit(1);
|
|
6659
6917
|
}
|
|
@@ -6764,7 +7022,7 @@ Setze diese Umgebungsvariablen:`);
|
|
|
6764
7022
|
process.exit(1);
|
|
6765
7023
|
}
|
|
6766
7024
|
} else if (command === "security") {
|
|
6767
|
-
if (!
|
|
7025
|
+
if (!fs30.existsSync(forgehiveDir)) {
|
|
6768
7026
|
console.error("Fehler: .forgehive/ nicht gefunden \u2014 f\xFChre zuerst `fh init` aus");
|
|
6769
7027
|
process.exit(1);
|
|
6770
7028
|
}
|
|
@@ -6850,8 +7108,8 @@ Setze diese Umgebungsvariablen:`);
|
|
|
6850
7108
|
`);
|
|
6851
7109
|
const report = generateSecurityReport(projectRoot, forgehiveDir, mode);
|
|
6852
7110
|
const text = formatSecurityReport(report);
|
|
6853
|
-
const reportPath =
|
|
6854
|
-
|
|
7111
|
+
const reportPath = path31.join(forgehiveDir, "security-report.md");
|
|
7112
|
+
fs30.writeFileSync(reportPath, text, "utf8");
|
|
6855
7113
|
console.log(text);
|
|
6856
7114
|
console.log(`
|
|
6857
7115
|
\u2713 Report gespeichert: ${reportPath}`);
|
|
@@ -6863,8 +7121,8 @@ Setze diese Umgebungsvariablen:`);
|
|
|
6863
7121
|
} else if (subcommand === "permissions") {
|
|
6864
7122
|
const { writePermissions: writePermissions2 } = await Promise.resolve().then(() => (init_harness(), harness_exports));
|
|
6865
7123
|
writePermissions2(forgehiveDir);
|
|
6866
|
-
const permPath =
|
|
6867
|
-
console.log(
|
|
7124
|
+
const permPath = path31.join(forgehiveDir, "harness", "permissions.yaml");
|
|
7125
|
+
console.log(fs30.readFileSync(permPath, "utf8"));
|
|
6868
7126
|
} else {
|
|
6869
7127
|
console.error(`Unbekannter security-Subcommand: ${subcommand}`);
|
|
6870
7128
|
console.error("Verf\xFCgbar: scan | deps | audit | report [gdpr|soc2|hipaa|none] | permissions");
|
|
@@ -6876,35 +7134,35 @@ Setze diese Umgebungsvariablen:`);
|
|
|
6876
7134
|
const failOnArg = allCiArgs.includes("--fail-on") ? allCiArgs[allCiArgs.indexOf("--fail-on") + 1] : "high";
|
|
6877
7135
|
const initFlag = allCiArgs.includes("--init");
|
|
6878
7136
|
if (initFlag) {
|
|
6879
|
-
const ghDir =
|
|
6880
|
-
|
|
6881
|
-
const outPath =
|
|
6882
|
-
|
|
7137
|
+
const ghDir = path31.join(projectRoot, ".github", "workflows");
|
|
7138
|
+
fs30.mkdirSync(ghDir, { recursive: true });
|
|
7139
|
+
const outPath = path31.join(ghDir, "forgehive.yml");
|
|
7140
|
+
fs30.writeFileSync(outPath, getGithubActionsTemplate(), "utf8");
|
|
6883
7141
|
console.log(`\u2714 GitHub Actions workflow geschrieben: ${outPath}`);
|
|
6884
7142
|
} else {
|
|
6885
7143
|
const report = generateCiReport(projectRoot, forgehiveDir, failOnArg);
|
|
6886
7144
|
const output = formatCiReport(report, format);
|
|
6887
7145
|
console.log(output);
|
|
6888
7146
|
if (format === "json") {
|
|
6889
|
-
|
|
6890
|
-
|
|
7147
|
+
fs30.mkdirSync(forgehiveDir, { recursive: true });
|
|
7148
|
+
fs30.writeFileSync(path31.join(forgehiveDir, "ci-report.json"), output, "utf8");
|
|
6891
7149
|
}
|
|
6892
7150
|
if (report.status === "fail") process.exit(1);
|
|
6893
7151
|
}
|
|
6894
7152
|
} else if (command === "map") {
|
|
6895
7153
|
const map2 = generateMap(projectRoot);
|
|
6896
7154
|
const md = formatMap(map2);
|
|
6897
|
-
const mapPath =
|
|
6898
|
-
|
|
6899
|
-
|
|
7155
|
+
const mapPath = path31.join(forgehiveDir, "map.md");
|
|
7156
|
+
fs30.mkdirSync(forgehiveDir, { recursive: true });
|
|
7157
|
+
fs30.writeFileSync(mapPath, md, "utf8");
|
|
6900
7158
|
console.log(md);
|
|
6901
7159
|
console.log(`
|
|
6902
7160
|
\u2714 Codebase-Map gespeichert: ${mapPath}`);
|
|
6903
7161
|
} else if (command === "onboard") {
|
|
6904
7162
|
const outputArg = rest.includes("--output") ? rest[rest.indexOf("--output") + 1] : null;
|
|
6905
|
-
const outputPath = outputArg ??
|
|
7163
|
+
const outputPath = outputArg ?? path31.join(projectRoot, "ONBOARDING.md");
|
|
6906
7164
|
const doc = generateOnboardingDoc(projectRoot, forgehiveDir);
|
|
6907
|
-
|
|
7165
|
+
fs30.writeFileSync(outputPath, doc, "utf8");
|
|
6908
7166
|
console.log(`\u2714 Onboarding-Dokument geschrieben: ${outputPath}`);
|
|
6909
7167
|
} else if (command === "changelog") {
|
|
6910
7168
|
const sinceArg = rest.includes("--since") ? rest[rest.indexOf("--since") + 1] : null;
|
|
@@ -6914,28 +7172,28 @@ Setze diese Umgebungsvariablen:`);
|
|
|
6914
7172
|
const commits = parseGitLog(rawLog);
|
|
6915
7173
|
let version = "unreleased";
|
|
6916
7174
|
try {
|
|
6917
|
-
const pkgPath =
|
|
6918
|
-
if (
|
|
6919
|
-
const pkg = JSON.parse(
|
|
7175
|
+
const pkgPath = path31.join(projectRoot, "package.json");
|
|
7176
|
+
if (fs30.existsSync(pkgPath)) {
|
|
7177
|
+
const pkg = JSON.parse(fs30.readFileSync(pkgPath, "utf8").replace(/^\s*\/\/.*$/gm, ""));
|
|
6920
7178
|
version = pkg.version ?? "unreleased";
|
|
6921
7179
|
}
|
|
6922
7180
|
} catch {
|
|
6923
7181
|
}
|
|
6924
7182
|
const md = formatChangelog(commits, version);
|
|
6925
|
-
const outputPath = outputArg ??
|
|
7183
|
+
const outputPath = outputArg ?? path31.join(projectRoot, "CHANGELOG.md");
|
|
6926
7184
|
let existing = "";
|
|
6927
|
-
if (
|
|
6928
|
-
|
|
7185
|
+
if (fs30.existsSync(outputPath)) existing = fs30.readFileSync(outputPath, "utf8");
|
|
7186
|
+
fs30.writeFileSync(outputPath, md + "\n\n" + existing, "utf8");
|
|
6929
7187
|
console.log(`\u2714 CHANGELOG.md aktualisiert: ${outputPath}`);
|
|
6930
7188
|
console.log(` ${commits.length} Commits verarbeitet`);
|
|
6931
7189
|
} else if (command === "metrics") {
|
|
6932
7190
|
const sinceArg = rest.includes("--since") ? rest[rest.indexOf("--since") + 1] : void 0;
|
|
6933
7191
|
const rawLog = getMetricsGitLog(projectRoot, sinceArg);
|
|
6934
7192
|
const stats = parseCommitStats(rawLog);
|
|
6935
|
-
const md = formatMetrics(stats,
|
|
6936
|
-
const metricsPath =
|
|
6937
|
-
|
|
6938
|
-
|
|
7193
|
+
const md = formatMetrics(stats, path31.basename(projectRoot));
|
|
7194
|
+
const metricsPath = path31.join(forgehiveDir, "metrics.md");
|
|
7195
|
+
fs30.mkdirSync(forgehiveDir, { recursive: true });
|
|
7196
|
+
fs30.writeFileSync(metricsPath, md, "utf8");
|
|
6939
7197
|
console.log(md);
|
|
6940
7198
|
console.log(`
|
|
6941
7199
|
\u2714 Metrics gespeichert: ${metricsPath}`);
|
|
@@ -6969,6 +7227,111 @@ Setze diese Umgebungsvariablen:`);
|
|
|
6969
7227
|
const result = runBackgroundAgent(forgehiveDir, issueUrl, agentId);
|
|
6970
7228
|
console.log(`\u2714 ${result.message}`);
|
|
6971
7229
|
console.log(` fh run status \u2014 aktive Sessions anzeigen`);
|
|
7230
|
+
} else if (command === "story") {
|
|
7231
|
+
const storiesDir = path31.join(forgehiveDir, "memory", "stories");
|
|
7232
|
+
if (subcommand === "create") {
|
|
7233
|
+
const title = rest.filter((r) => !r.startsWith("--")).join(" ");
|
|
7234
|
+
const epicArg = rest.includes("--epic") ? rest[rest.indexOf("--epic") + 1] : void 0;
|
|
7235
|
+
const pointsArg = rest.includes("--points") ? parseInt(rest[rest.indexOf("--points") + 1], 10) : void 0;
|
|
7236
|
+
if (!title) {
|
|
7237
|
+
console.error("Usage: fh story create <titel> [--epic EPC-N] [--points N]");
|
|
7238
|
+
process.exit(1);
|
|
7239
|
+
}
|
|
7240
|
+
const story = createStory(storiesDir, title, epicArg);
|
|
7241
|
+
if (pointsArg) updateStoryPoints(storiesDir, story.id, pointsArg);
|
|
7242
|
+
console.log(`\u2714 ${story.id} erstellt: ${path31.join(storiesDir, story.id + ".md")}`);
|
|
7243
|
+
console.log(` Bearbeite die Datei um Acceptance Criteria hinzuzuf\xFCgen.`);
|
|
7244
|
+
} else if (subcommand === "list") {
|
|
7245
|
+
const epicFilter = rest.includes("--epic") ? rest[rest.indexOf("--epic") + 1] : null;
|
|
7246
|
+
let stories = listStories(storiesDir);
|
|
7247
|
+
if (epicFilter) stories = stories.filter((s) => s.epicId === epicFilter);
|
|
7248
|
+
if (stories.length === 0) {
|
|
7249
|
+
console.log("Keine Stories gefunden.");
|
|
7250
|
+
} else {
|
|
7251
|
+
for (const s of stories) {
|
|
7252
|
+
const pts = s.points !== null ? ` [${s.points}pt]` : " [?pt]";
|
|
7253
|
+
const epic = s.epicId ? ` (${s.epicId})` : "";
|
|
7254
|
+
console.log(` ${s.id}${pts}${epic} \u2014 ${s.title} [${s.status}]`);
|
|
7255
|
+
}
|
|
7256
|
+
}
|
|
7257
|
+
} else if (subcommand === "done") {
|
|
7258
|
+
const id = rest[0];
|
|
7259
|
+
const pointsArg = rest.includes("--points") ? parseInt(rest[rest.indexOf("--points") + 1], 10) : void 0;
|
|
7260
|
+
if (!id) {
|
|
7261
|
+
console.error("Usage: fh story done <US-N> [--points N]");
|
|
7262
|
+
process.exit(1);
|
|
7263
|
+
}
|
|
7264
|
+
if (pointsArg) updateStoryPoints(storiesDir, id, pointsArg);
|
|
7265
|
+
updateStoryStatus(storiesDir, id, "done");
|
|
7266
|
+
console.log(`\u2714 ${id} als done markiert`);
|
|
7267
|
+
} else if (subcommand === "show") {
|
|
7268
|
+
const id = rest[0];
|
|
7269
|
+
if (!id) {
|
|
7270
|
+
console.error("Usage: fh story show <US-N>");
|
|
7271
|
+
process.exit(1);
|
|
7272
|
+
}
|
|
7273
|
+
const story = getStory(storiesDir, id);
|
|
7274
|
+
if (!story) {
|
|
7275
|
+
console.error(`Story ${id} nicht gefunden`);
|
|
7276
|
+
process.exit(1);
|
|
7277
|
+
}
|
|
7278
|
+
console.log(formatStoryCard(story));
|
|
7279
|
+
} else {
|
|
7280
|
+
console.error("Verf\xFCgbar: fh story create | list | show | done");
|
|
7281
|
+
}
|
|
7282
|
+
} else if (command === "epic") {
|
|
7283
|
+
const epicsDir = path31.join(forgehiveDir, "memory", "epics");
|
|
7284
|
+
const storiesDir = path31.join(forgehiveDir, "memory", "stories");
|
|
7285
|
+
if (subcommand === "create") {
|
|
7286
|
+
const title = rest.filter((r) => !r.startsWith("--")).join(" ");
|
|
7287
|
+
const goalArg = rest.includes("--goal") ? rest[rest.indexOf("--goal") + 1] : void 0;
|
|
7288
|
+
if (!title) {
|
|
7289
|
+
console.error("Usage: fh epic create <titel> [--goal <ziel>]");
|
|
7290
|
+
process.exit(1);
|
|
7291
|
+
}
|
|
7292
|
+
const epic = createEpic(epicsDir, title, goalArg);
|
|
7293
|
+
console.log(`\u2714 ${epic.id} erstellt: ${path31.join(epicsDir, epic.id + ".md")}`);
|
|
7294
|
+
} else if (subcommand === "list") {
|
|
7295
|
+
const epics = listEpics(epicsDir);
|
|
7296
|
+
if (epics.length === 0) {
|
|
7297
|
+
console.log("Keine Epics gefunden.");
|
|
7298
|
+
} else {
|
|
7299
|
+
for (const e of epics)
|
|
7300
|
+
console.log(` ${e.id} \u2014 ${e.title} [${e.status}] (${e.stories.length} Stories)`);
|
|
7301
|
+
}
|
|
7302
|
+
} else if (subcommand === "show") {
|
|
7303
|
+
const id = rest[0];
|
|
7304
|
+
if (!id) {
|
|
7305
|
+
console.error("Usage: fh epic show <EPC-N>");
|
|
7306
|
+
process.exit(1);
|
|
7307
|
+
}
|
|
7308
|
+
const epic = getEpic(epicsDir, id);
|
|
7309
|
+
if (!epic) {
|
|
7310
|
+
console.error(`Epic ${id} nicht gefunden`);
|
|
7311
|
+
process.exit(1);
|
|
7312
|
+
}
|
|
7313
|
+
const stories = listStories(storiesDir).filter((s) => s.epicId === id);
|
|
7314
|
+
console.log(formatEpicCard(epic, stories));
|
|
7315
|
+
} else {
|
|
7316
|
+
console.error("Verf\xFCgbar: fh epic create | list | show");
|
|
7317
|
+
}
|
|
7318
|
+
} else if (command === "velocity") {
|
|
7319
|
+
const velocityFile = path31.join(forgehiveDir, "memory", "velocity.md");
|
|
7320
|
+
if (subcommand === "record") {
|
|
7321
|
+
const sprintNum = parseInt(rest[0] ?? "0", 10);
|
|
7322
|
+
const committed = rest.includes("--committed") ? parseInt(rest[rest.indexOf("--committed") + 1], 10) : NaN;
|
|
7323
|
+
const delivered = rest.includes("--delivered") ? parseInt(rest[rest.indexOf("--delivered") + 1], 10) : NaN;
|
|
7324
|
+
if (!sprintNum || isNaN(committed) || isNaN(delivered)) {
|
|
7325
|
+
console.error("Usage: fh velocity record <sprint-num> --committed N --delivered N");
|
|
7326
|
+
process.exit(1);
|
|
7327
|
+
}
|
|
7328
|
+
recordVelocity(velocityFile, sprintNum, committed, delivered);
|
|
7329
|
+
const avg = getRollingAverage(getVelocityHistory(velocityFile));
|
|
7330
|
+
console.log(`\u2714 Sprint ${sprintNum} gespeichert. Rolling Average: ${avg} Punkte`);
|
|
7331
|
+
} else {
|
|
7332
|
+
const history = getVelocityHistory(velocityFile);
|
|
7333
|
+
console.log(formatVelocityReport(history));
|
|
7334
|
+
}
|
|
6972
7335
|
} else {
|
|
6973
7336
|
const cmd = [command, subcommand].filter(Boolean).join(" ") || "(kein)";
|
|
6974
7337
|
console.error(`Unbekannter Befehl: ${cmd}`);
|
|
@@ -7,11 +7,15 @@ You are running a Sprint Planning session using the ForgeHive workflow.
|
|
|
7
7
|
1. Read `.forgehive/capabilities.yaml` — understand the tech stack and constraints
|
|
8
8
|
2. Read `.forgehive/memory/MEMORY.md` and all linked memory files — load project context
|
|
9
9
|
3. Check if `.forgehive/memory/sprint.md` exists — if so, show the last sprint summary first
|
|
10
|
-
4.
|
|
10
|
+
4. Check if `.forgehive/memory/velocity.md` exists — if so, show rolling average as capacity hint
|
|
11
|
+
5. Run `fh scan --check` to verify the codebase snapshot is current
|
|
11
12
|
|
|
12
13
|
### Step 2: Collect backlog items
|
|
13
14
|
|
|
14
|
-
Ask the user: **"Welche Items kommen in den Sprint? Liste sie auf —
|
|
15
|
+
Ask the user: **"Welche Items kommen in den Sprint? Liste sie auf — oder soll ich Backlog-Stories laden?"**
|
|
16
|
+
|
|
17
|
+
**If stories exist** in `.forgehive/memory/stories/` with status `backlog`:
|
|
18
|
+
Run `fh story list` to show available stories. Ask: **"Welche dieser Stories kommen in den Sprint?"**
|
|
15
19
|
|
|
16
20
|
**If Linear MCP is available** (check if `.mcp.json` contains `linear`):
|
|
17
21
|
Use the Linear MCP tool to fetch open issues:
|
|
@@ -25,7 +29,7 @@ Show the fetched issues and ask: **"Welche davon kommen in den Sprint?"**
|
|
|
25
29
|
gh issue list --state open --label "sprint-candidate" --limit 20
|
|
26
30
|
```
|
|
27
31
|
|
|
28
|
-
**If no MCP
|
|
32
|
+
**If no stories/MCP:**
|
|
29
33
|
Accept free-text input — one item per line.
|
|
30
34
|
|
|
31
35
|
### Step 3: Clarify and refine
|
|
@@ -37,44 +41,59 @@ For each item, ask one clarifying question if needed:
|
|
|
37
41
|
|
|
38
42
|
Do NOT ask more than one question per item. If the item is clear, skip this step.
|
|
39
43
|
|
|
40
|
-
### Step 4: Estimate
|
|
44
|
+
### Step 4: Estimate with Fibonacci Points
|
|
41
45
|
|
|
42
|
-
Estimate each item using
|
|
46
|
+
Estimate each item using Fibonacci story points:
|
|
43
47
|
|
|
44
|
-
|
|
|
48
|
+
| Points | Meaning | Typical scope |
|
|
45
49
|
|---|---|---|
|
|
46
|
-
|
|
|
47
|
-
|
|
|
48
|
-
|
|
|
49
|
-
|
|
|
50
|
-
|
|
|
50
|
+
| 1 | Trivial | Config change, copy fix, 1-line fix |
|
|
51
|
+
| 2 | Small | Simple isolated change |
|
|
52
|
+
| 3 | Medium-small | Small feature, isolated fix |
|
|
53
|
+
| 5 | Medium | Feature with tests, moderate complexity |
|
|
54
|
+
| 8 | Large | Complex feature, multiple files |
|
|
55
|
+
| 13 | Extra-large | Should be broken down |
|
|
56
|
+
|
|
57
|
+
**T-Shirt aliases:** XS=1, S=2, M=5, L=8, XL=13
|
|
51
58
|
|
|
52
|
-
Flag any
|
|
59
|
+
Flag any 13-point items: **"Dieses Item ist zu groß für einen Sprint — soll ich es aufteilen?"**
|
|
60
|
+
|
|
61
|
+
If items were loaded from `.forgehive/memory/stories/`, update their points:
|
|
62
|
+
```bash
|
|
63
|
+
fh story <US-N> --points <N>
|
|
64
|
+
```
|
|
53
65
|
|
|
54
66
|
### Step 5: Prioritize
|
|
55
67
|
|
|
56
68
|
Sort items into three buckets:
|
|
57
69
|
|
|
58
|
-
**Must (Sprint-Ziel)** — delivers the sprint goal, blocks other work, or is overdue
|
|
59
|
-
**Should (Best Effort)** — important but not blocking
|
|
60
|
-
**Could (Nice to Have)** — do if capacity allows
|
|
70
|
+
**Must (Sprint-Ziel)** — delivers the sprint goal, blocks other work, or is overdue
|
|
71
|
+
**Should (Best Effort)** — important but not blocking
|
|
72
|
+
**Could (Nice to Have)** — do if capacity allows
|
|
61
73
|
|
|
62
74
|
Suggest a sprint goal in one sentence based on the Must items.
|
|
63
75
|
|
|
64
76
|
### Step 6: Check capacity
|
|
65
77
|
|
|
66
|
-
|
|
78
|
+
Show velocity hint if available:
|
|
79
|
+
```bash
|
|
80
|
+
fh velocity show
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Ask: **"Wie viele Punkte habt ihr im Sprint?"**
|
|
67
84
|
|
|
68
|
-
|
|
85
|
+
Default: rolling average from velocity history, or 20 points for a 2-week sprint with one developer.
|
|
86
|
+
|
|
87
|
+
Calculate if Must + Should items fit. If they don't, move the lowest-priority Should items to Could.
|
|
69
88
|
|
|
70
89
|
Show a capacity summary:
|
|
71
90
|
```
|
|
72
|
-
Sprint-Kapazität:
|
|
73
|
-
Must: [sum]
|
|
74
|
-
Should: [sum]
|
|
75
|
-
Could: [sum]
|
|
76
|
-
|
|
77
|
-
Geplant: [must+should] /
|
|
91
|
+
Sprint-Kapazität: 20 Punkte
|
|
92
|
+
Must: [sum] Punkte
|
|
93
|
+
Should: [sum] Punkte
|
|
94
|
+
Could: [sum] Punkte
|
|
95
|
+
─────────────────────
|
|
96
|
+
Geplant: [must+should] / 20 Punkte
|
|
78
97
|
```
|
|
79
98
|
|
|
80
99
|
### Step 7: Output the sprint plan
|
|
@@ -86,16 +105,16 @@ Write the sprint plan to `.forgehive/memory/sprint.md` in this format:
|
|
|
86
105
|
|
|
87
106
|
**Ziel:** [one sentence sprint goal]
|
|
88
107
|
|
|
89
|
-
**Kapazität:** [X]
|
|
108
|
+
**Kapazität:** [X] Punkte
|
|
90
109
|
|
|
91
110
|
## Must
|
|
92
|
-
- [ ] [Item] ([
|
|
111
|
+
- [ ] [ID] [Item] ([points]pt) — [one-line description]
|
|
93
112
|
|
|
94
|
-
## Should
|
|
95
|
-
- [ ] [Item] ([
|
|
113
|
+
## Should
|
|
114
|
+
- [ ] [ID] [Item] ([points]pt) — [one-line description]
|
|
96
115
|
|
|
97
116
|
## Could
|
|
98
|
-
- [ ] [Item] ([
|
|
117
|
+
- [ ] [ID] [Item] ([points]pt) — [one-line description]
|
|
99
118
|
|
|
100
119
|
## Offen / Blocked
|
|
101
120
|
- [any blocked items with reason]
|
|
@@ -104,10 +123,25 @@ Write the sprint plan to `.forgehive/memory/sprint.md` in this format:
|
|
|
104
123
|
*Erstellt mit fh sprint — [timestamp]*
|
|
105
124
|
```
|
|
106
125
|
|
|
107
|
-
Confirm with the user: **"Sprint Plan gespeichert
|
|
126
|
+
Confirm with the user: **"Sprint Plan gespeichert. Soll ich für jedes Must-Item direkt einen Branch anlegen?"**
|
|
108
127
|
|
|
109
|
-
If yes: create branches
|
|
128
|
+
If yes: create branches following `feat/<slug>`, `fix/<slug>`, `chore/<slug>`.
|
|
110
129
|
|
|
111
130
|
### Step 8: Update project memory
|
|
112
131
|
|
|
113
|
-
Append the sprint goal to `.forgehive/memory/project.md` under a `## Aktueller Sprint` section
|
|
132
|
+
Append the sprint goal to `.forgehive/memory/project.md` under a `## Aktueller Sprint` section.
|
|
133
|
+
|
|
134
|
+
### Step 9: Record velocity (at sprint end)
|
|
135
|
+
|
|
136
|
+
When the user runs `/fh-sprint` and mentions "Sprint ist fertig" or "Sprint abgeschlossen":
|
|
137
|
+
|
|
138
|
+
1. Ask: **"Wie viele Punkte habt ihr tatsächlich geliefert?"**
|
|
139
|
+
2. Read the committed points from `sprint.md`
|
|
140
|
+
3. Record velocity:
|
|
141
|
+
```bash
|
|
142
|
+
fh velocity record <N> --committed <committed> --delivered <delivered>
|
|
143
|
+
```
|
|
144
|
+
4. Show updated velocity report:
|
|
145
|
+
```bash
|
|
146
|
+
fh velocity show
|
|
147
|
+
```
|