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,2364 +0,0 @@
1
- # ForgeHive v0.5 — NextGen Gap Implementation Plan
2
-
3
- > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
-
5
- **Goal:** Close all competitive gaps from the v0.5 analysis: AGENTS.md cross-tool output, harness constraints, memory aging + ADR system, context drift detection, session cost tracking, multi-model party routing, worktree-isolated party runs, MCP credential management, and Smithery registry integration.
6
-
7
- **Architecture:** Ten independent modules extending the existing TypeScript ESM CLI. Each is a focused export + CLI surface added to `src/cli.ts`. No new runtime npm dependencies — only Node.js built-ins (fs, os, crypto, child_process), js-yaml, and `curl` (available macOS/Linux) for the registry HTTP call. All modules use the same patterns as existing code: synchronous functions, node:test, tmpDir fixtures.
8
-
9
- **Tech Stack:** TypeScript 5.8 ESM, Node.js built-ins, js-yaml 4.1, node:test runner, esbuild bundler. Test command: `node --import tsx/esm --test test/*.test.ts`.
10
-
11
- ---
12
-
13
- ## File Structure
14
-
15
- **New source files:**
16
- - `src/agents-md.ts` — `writeAgentsMd(projectRoot, scanResult, capMap): void`
17
- - `src/harness.ts` — `writeHarness(forgehiveDir, scanResult, capMap): void`
18
- - `src/adr.ts` — `createAdr(forgehiveDir, projectRoot, title): string`, `listAdrs(forgehiveDir): AdrEntry[]`
19
- - `src/cost.ts` — `parseCostSessions(projectRoot): SessionCost[]`, `formatCostReport(sessions, range): string`
20
- - `src/mcp-auth.ts` — `setCredentials`, `getCredentials`, `listCredentialServices`, `removeCredentials`
21
- - `src/mcp-registry.ts` — `searchRegistry(query): RegistryServer[]`, `addMcpFromRegistry(projectRoot, forgehiveDir, packageName, envKeys): void`
22
-
23
- **Modified source files:**
24
- - `src/memory.ts` — add `pruneMemory(forgehiveDir, days, remove?): PruneResult`, `getMemoryMeta(forgehiveDir): MemoryFileMeta[]`; update stubs with `last_updated` frontmatter
25
- - `src/party.ts` — extend `PartySet` with `model_map?`, add `setAgentModel`, `runParty`, `getPartyStatus`, `cleanupPartyWorktrees`
26
- - `src/status.ts` — add `checkDrift(projectRoot, forgehiveDir): DriftInfo`, extend `projectStatus()` with drift line
27
- - `src/writer.ts` — call `writeAgentsMd()` from `writeForgehiveDir()`, call `writeHarness()` from `initForgehiveRuntime()`
28
- - `src/cli.ts` — add handlers for all new subcommands (see each task)
29
-
30
- **New test files:** `test/agents-md.test.ts`, `test/harness.test.ts`, `test/adr.test.ts`, `test/cost.test.ts`, `test/mcp-auth.test.ts`, `test/mcp-registry.test.ts`
31
-
32
- **Modified test files:** `test/memory.test.ts`, `test/party.test.ts`, `test/status.test.ts`
33
-
34
- ---
35
-
36
- ### Task 1: AGENTS.md Generation
37
-
38
- **Files:**
39
- - Create: `src/agents-md.ts`
40
- - Modify: `src/writer.ts` (lines 11–43)
41
- - Test: `test/agents-md.test.ts`
42
-
43
- - [ ] **Step 1: Write failing tests**
44
-
45
- ```typescript
46
- // test/agents-md.test.ts
47
- import { describe, it, beforeEach, afterEach } from "node:test";
48
- import assert from "node:assert/strict";
49
- import fs from "node:fs";
50
- import path from "node:path";
51
- import os from "node:os";
52
- import { writeAgentsMd } from "../src/agents-md.ts";
53
-
54
- const mockCapMap = {
55
- confirmed: [
56
- { id: "typescript", source: "tsconfig.json", confidence: "confirmed" as const },
57
- { id: "nodejs", source: "package.json", confidence: "confirmed" as const },
58
- ],
59
- inferred: [],
60
- };
61
- const mockScan = { scanned_at: "2026-05-11", project_root: "", signals: [], packages: [] };
62
-
63
- describe("writeAgentsMd()", () => {
64
- let tmpDir: string;
65
- beforeEach(() => {
66
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "forgehive-agents-md-"));
67
- mockScan.project_root = tmpDir;
68
- });
69
- afterEach(() => fs.rmSync(tmpDir, { recursive: true, force: true }));
70
-
71
- it("creates AGENTS.md at project root", () => {
72
- writeAgentsMd(tmpDir, mockScan, mockCapMap);
73
- assert.ok(fs.existsSync(path.join(tmpDir, "AGENTS.md")));
74
- });
75
-
76
- it("includes confirmed capabilities", () => {
77
- writeAgentsMd(tmpDir, mockScan, mockCapMap);
78
- const content = fs.readFileSync(path.join(tmpDir, "AGENTS.md"), "utf8");
79
- assert.ok(content.includes("typescript"));
80
- assert.ok(content.includes("nodejs"));
81
- });
82
-
83
- it("uses project name from package.json", () => {
84
- fs.writeFileSync(
85
- path.join(tmpDir, "package.json"),
86
- JSON.stringify({ name: "my-cool-project", scripts: { test: "jest", build: "tsc" } }),
87
- "utf8"
88
- );
89
- writeAgentsMd(tmpDir, mockScan, mockCapMap);
90
- const content = fs.readFileSync(path.join(tmpDir, "AGENTS.md"), "utf8");
91
- assert.ok(content.includes("my-cool-project"));
92
- assert.ok(content.includes("npm run build"));
93
- assert.ok(content.includes("npm test"));
94
- });
95
-
96
- it("is idempotent — overwrites on re-run", () => {
97
- writeAgentsMd(tmpDir, mockScan, mockCapMap);
98
- writeAgentsMd(tmpDir, mockScan, mockCapMap);
99
- const files = fs.readdirSync(tmpDir).filter(f => f === "AGENTS.md");
100
- assert.equal(files.length, 1);
101
- });
102
-
103
- it("mentions .forgehive/memory and skills", () => {
104
- writeAgentsMd(tmpDir, mockScan, mockCapMap);
105
- const content = fs.readFileSync(path.join(tmpDir, "AGENTS.md"), "utf8");
106
- assert.ok(content.includes(".forgehive/memory"));
107
- assert.ok(content.includes(".forgehive/skills"));
108
- });
109
- });
110
- ```
111
-
112
- - [ ] **Step 2: Run test to confirm it fails**
113
-
114
- ```bash
115
- cd /path/to/project && node --import tsx/esm --test test/agents-md.test.ts 2>&1 | head -10
116
- ```
117
-
118
- Expected: `Error: Cannot find module '../src/agents-md.ts'`
119
-
120
- - [ ] **Step 3: Implement `src/agents-md.ts`**
121
-
122
- ```typescript
123
- import fs from "node:fs";
124
- import path from "node:path";
125
- import type { ScanResult } from "./types.ts";
126
- import type { CapabilityMap } from "./lookup-table.ts";
127
-
128
- export function writeAgentsMd(
129
- projectRoot: string,
130
- scanResult: ScanResult,
131
- capMap: CapabilityMap
132
- ): void {
133
- const pkgPath = path.join(projectRoot, "package.json");
134
- let projectName = path.basename(projectRoot);
135
- let buildLine = "";
136
- let testLine = "";
137
-
138
- if (fs.existsSync(pkgPath)) {
139
- try {
140
- const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8")) as {
141
- name?: string;
142
- scripts?: Record<string, string>;
143
- };
144
- if (pkg.name) projectName = pkg.name;
145
- if (pkg.scripts?.build) buildLine = "\nBuild: `npm run build`";
146
- if (pkg.scripts?.test) testLine = "\nTest: `npm test`";
147
- } catch { /* ignore */ }
148
- }
149
-
150
- const capLines = capMap.confirmed.length > 0
151
- ? capMap.confirmed.slice(0, 10).map(c => `- **${c.id}** (via ${c.source})`).join("\n")
152
- : "- (run `fh init` to populate)";
153
-
154
- const content = [
155
- `# ${projectName}`,
156
- "",
157
- "> AI context for Cursor, GitHub Copilot, Claude Code, Gemini CLI, Codex, Windsurf.",
158
- "> Generated by ForgeHive. Regenerate with: `fh init`",
159
- "",
160
- "## Instructions",
161
- "",
162
- "Before making changes:",
163
- "1. Read `.forgehive/capabilities.yaml` — confirmed project capabilities",
164
- "2. Read `.forgehive/memory/MEMORY.md` — persistent project context",
165
- "3. Load relevant skills from `.forgehive/skills/INDEX.yaml`",
166
- "",
167
- "## Build & Test",
168
- buildLine,
169
- testLine,
170
- "",
171
- "## Capabilities",
172
- "",
173
- capLines,
174
- "",
175
- "## Memory",
176
- "",
177
- "Persistent context in `.forgehive/memory/`. Read `MEMORY.md` for the index.",
178
- "",
179
- "## Skills",
180
- "",
181
- "Context-aware skills in `.forgehive/skills/`. Check `INDEX.yaml` for available skills.",
182
- ].join("\n");
183
-
184
- fs.writeFileSync(path.join(projectRoot, "AGENTS.md"), content + "\n", "utf8");
185
- }
186
- ```
187
-
188
- - [ ] **Step 4: Wire into `src/writer.ts`**
189
-
190
- Add import at top of `src/writer.ts` (after existing imports):
191
- ```typescript
192
- import { writeAgentsMd } from "./agents-md.ts";
193
- ```
194
-
195
- Add call at end of `writeForgehiveDir()` body, after the `mergeClaudeMd(projectRoot, claudeMdBlock);` line:
196
- ```typescript
197
- writeAgentsMd(projectRoot, scanResult, capMap);
198
- ```
199
-
200
- - [ ] **Step 5: Run all tests**
201
-
202
- ```bash
203
- node --import tsx/esm --test test/*.test.ts 2>&1 | tail -8
204
- ```
205
-
206
- Expected:
207
- ```
208
- # pass 128
209
- # fail 0
210
- ```
211
-
212
- - [ ] **Step 6: Commit**
213
-
214
- ```bash
215
- git add src/agents-md.ts src/writer.ts test/agents-md.test.ts
216
- git commit -m "feat: generate AGENTS.md on fh init — cross-tool AI context (Cursor, Copilot, Gemini)"
217
- ```
218
-
219
- ---
220
-
221
- ### Task 2: Harness File Generation
222
-
223
- **Files:**
224
- - Create: `src/harness.ts`
225
- - Modify: `src/writer.ts:51–125` (initForgehiveRuntime)
226
- - Test: `test/harness.test.ts`
227
-
228
- - [ ] **Step 1: Write failing tests**
229
-
230
- ```typescript
231
- // test/harness.test.ts
232
- import { describe, it, beforeEach, afterEach } from "node:test";
233
- import assert from "node:assert/strict";
234
- import fs from "node:fs";
235
- import path from "node:path";
236
- import os from "node:os";
237
- import { writeHarness } from "../src/harness.ts";
238
-
239
- const mockCapMap = {
240
- confirmed: [
241
- { id: "typescript", source: "tsconfig.json", confidence: "confirmed" as const },
242
- ],
243
- inferred: [],
244
- };
245
- const mockScan = { scanned_at: "2026-05-11", project_root: "", signals: [], packages: [] };
246
-
247
- describe("writeHarness()", () => {
248
- let tmpDir: string;
249
- let forgehiveDir: string;
250
- beforeEach(() => {
251
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "forgehive-harness-"));
252
- forgehiveDir = path.join(tmpDir, ".forgehive");
253
- fs.mkdirSync(forgehiveDir, { recursive: true });
254
- mockScan.project_root = tmpDir;
255
- });
256
- afterEach(() => fs.rmSync(tmpDir, { recursive: true, force: true }));
257
-
258
- it("creates .forgehive/harness/ directory", () => {
259
- writeHarness(forgehiveDir, mockScan, mockCapMap);
260
- assert.ok(fs.existsSync(path.join(forgehiveDir, "harness")));
261
- });
262
-
263
- it("creates architecture.md with detected capabilities", () => {
264
- writeHarness(forgehiveDir, mockScan, mockCapMap);
265
- const content = fs.readFileSync(path.join(forgehiveDir, "harness", "architecture.md"), "utf8");
266
- assert.ok(content.includes("typescript"));
267
- assert.ok(content.includes("Architecture Constraints"));
268
- });
269
-
270
- it("creates constraints.yaml with defaults", () => {
271
- writeHarness(forgehiveDir, mockScan, mockCapMap);
272
- const content = fs.readFileSync(path.join(forgehiveDir, "harness", "constraints.yaml"), "utf8");
273
- assert.ok(content.includes("max_lines"));
274
- assert.ok(content.includes("require_tests"));
275
- });
276
-
277
- it("is idempotent — does not overwrite existing files", () => {
278
- writeHarness(forgehiveDir, mockScan, mockCapMap);
279
- const archPath = path.join(forgehiveDir, "harness", "architecture.md");
280
- fs.writeFileSync(archPath, "# Custom content", "utf8");
281
- writeHarness(forgehiveDir, mockScan, mockCapMap);
282
- assert.equal(fs.readFileSync(archPath, "utf8"), "# Custom content");
283
- });
284
- });
285
- ```
286
-
287
- - [ ] **Step 2: Run test to confirm it fails**
288
-
289
- ```bash
290
- node --import tsx/esm --test test/harness.test.ts 2>&1 | head -5
291
- ```
292
-
293
- Expected: `Error: Cannot find module '../src/harness.ts'`
294
-
295
- - [ ] **Step 3: Implement `src/harness.ts`**
296
-
297
- ```typescript
298
- import fs from "node:fs";
299
- import path from "node:path";
300
- import yaml from "js-yaml";
301
- import type { ScanResult } from "./types.ts";
302
- import type { CapabilityMap } from "./lookup-table.ts";
303
-
304
- export function writeHarness(
305
- forgehiveDir: string,
306
- scanResult: ScanResult,
307
- capMap: CapabilityMap
308
- ): void {
309
- const harnessDir = path.join(forgehiveDir, "harness");
310
- fs.mkdirSync(harnessDir, { recursive: true });
311
-
312
- const archPath = path.join(harnessDir, "architecture.md");
313
- if (!fs.existsSync(archPath)) {
314
- const capLines = capMap.confirmed.length > 0
315
- ? capMap.confirmed.map(c => `- **${c.id}**: detected via ${c.source}`).join("\n")
316
- : "- (none detected)";
317
-
318
- fs.writeFileSync(archPath, [
319
- "# Architecture Constraints",
320
- "",
321
- "> Generated by ForgeHive. Edit to reflect actual project conventions.",
322
- "> Referenced in CLAUDE.md and AGENTS.md.",
323
- "",
324
- "## Detected Stack",
325
- "",
326
- capLines,
327
- "",
328
- "## Rules",
329
- "",
330
- "- Do not add new dependencies without a brief justification in the PR",
331
- "- Files should stay under 300 lines — split by responsibility when exceeded",
332
- "- New exported functions require a corresponding test",
333
- "- Never force-push to protected branches (main, master)",
334
- "- Database migrations must be reviewed before running in production",
335
- "",
336
- "## Naming Conventions",
337
- "",
338
- "_Update based on actual codebase conventions found in .forgehive/skills/generated/_",
339
- ].join("\n") + "\n", "utf8");
340
- }
341
-
342
- const constraintsPath = path.join(harnessDir, "constraints.yaml");
343
- if (!fs.existsSync(constraintsPath)) {
344
- fs.writeFileSync(
345
- constraintsPath,
346
- yaml.dump({
347
- file_complexity: { max_lines: 300, max_functions_per_file: 15 },
348
- require_tests_for_new_exports: true,
349
- no_new_dependencies_without_review: true,
350
- protected_branches: ["main", "master"],
351
- }),
352
- "utf8"
353
- );
354
- }
355
- }
356
- ```
357
-
358
- - [ ] **Step 4: Wire into `src/writer.ts` — `initForgehiveRuntime`**
359
-
360
- Add import at top of `src/writer.ts`:
361
- ```typescript
362
- import { writeHarness } from "./harness.ts";
363
- ```
364
-
365
- Add call at the end of `initForgehiveRuntime()`, after `writeGuardrailHooks(projectRoot, forgehiveDir)`:
366
- ```typescript
367
- writeHarness(forgehiveDir, yaml.load(
368
- fs.readFileSync(path.join(forgehiveDir, "scan-result.yaml"), "utf8")
369
- ) as ScanResult, { confirmed: [], inferred: [] });
370
- ```
371
-
372
- Wait — `initForgehiveRuntime` does not receive `scanResult` or `capMap`. It only receives `forgehiveDir` and `runtimeDir`. The scan-result.yaml has already been written by `writeForgehiveDir`. Read it back:
373
-
374
- Replace the last line of `initForgehiveRuntime` (the `writeGuardrailHooks` call) with:
375
-
376
- ```typescript
377
- writeGuardrailHooks(projectRoot, forgehiveDir);
378
-
379
- // Generate harness from already-written scan-result
380
- const scanPath = path.join(forgehiveDir, "scan-result.yaml");
381
- if (fs.existsSync(scanPath)) {
382
- try {
383
- const scanResult = yaml.load(fs.readFileSync(scanPath, "utf8")) as ScanResult;
384
- writeHarness(forgehiveDir, scanResult, { confirmed: [], inferred: [] });
385
- } catch { /* ignore if scan-result is missing or malformed */ }
386
- }
387
- ```
388
-
389
- Note: capabilities are not available at this point without calling `mapSignalsToCapabilities` again. Use an empty capMap — the architecture.md will still be created with a note to update it.
390
-
391
- - [ ] **Step 5: Run all tests**
392
-
393
- ```bash
394
- node --import tsx/esm --test test/*.test.ts 2>&1 | tail -8
395
- ```
396
-
397
- Expected:
398
- ```
399
- # pass 132
400
- # fail 0
401
- ```
402
-
403
- - [ ] **Step 6: Commit**
404
-
405
- ```bash
406
- git add src/harness.ts src/writer.ts test/harness.test.ts
407
- git commit -m "feat: generate .forgehive/harness/ with architecture constraints on fh init"
408
- ```
409
-
410
- ---
411
-
412
- ### Task 3: Memory Aging + Prune
413
-
414
- **Files:**
415
- - Modify: `src/memory.ts`
416
- - Modify: `src/cli.ts` (memory command handler)
417
- - Test: `test/memory.test.ts` (add tests to existing file)
418
-
419
- - [ ] **Step 1: Write failing tests** (append to `test/memory.test.ts`)
420
-
421
- ```typescript
422
- import { pruneMemory, getMemoryMeta } from "../src/memory.ts";
423
-
424
- describe("getMemoryMeta()", () => {
425
- let tmpDir: string;
426
- beforeEach(() => {
427
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "forgehive-mem-meta-"));
428
- });
429
- afterEach(() => fs.rmSync(tmpDir, { recursive: true, force: true }));
430
-
431
- it("returns empty array when memory dir missing", () => {
432
- const meta = getMemoryMeta(tmpDir);
433
- assert.deepEqual(meta, []);
434
- });
435
-
436
- it("returns null lastUpdated when file has no frontmatter", () => {
437
- fs.mkdirSync(path.join(tmpDir, "memory"), { recursive: true });
438
- fs.writeFileSync(path.join(tmpDir, "memory", "project.md"), "# project\nno frontmatter", "utf8");
439
- const meta = getMemoryMeta(tmpDir);
440
- assert.equal(meta.length, 1);
441
- assert.equal(meta[0].lastUpdated, null);
442
- });
443
-
444
- it("parses last_updated from frontmatter", () => {
445
- fs.mkdirSync(path.join(tmpDir, "memory"), { recursive: true });
446
- fs.writeFileSync(
447
- path.join(tmpDir, "memory", "project.md"),
448
- "---\ntype: project\nlast_updated: 2020-01-01\n---\n\n# old content",
449
- "utf8"
450
- );
451
- const meta = getMemoryMeta(tmpDir);
452
- assert.equal(meta.length, 1);
453
- assert.ok(meta[0].lastUpdated instanceof Date);
454
- assert.ok((meta[0].daysSinceUpdate ?? 0) > 365);
455
- });
456
- });
457
-
458
- describe("pruneMemory()", () => {
459
- let tmpDir: string;
460
- beforeEach(() => {
461
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "forgehive-prune-"));
462
- fs.mkdirSync(path.join(tmpDir, "memory"), { recursive: true });
463
- });
464
- afterEach(() => fs.rmSync(tmpDir, { recursive: true, force: true }));
465
-
466
- it("returns empty array when nothing stale", () => {
467
- const today = new Date().toISOString().slice(0, 10);
468
- fs.writeFileSync(
469
- path.join(tmpDir, "memory", "project.md"),
470
- `---\ntype: project\nlast_updated: ${today}\n---\n\n# fresh`,
471
- "utf8"
472
- );
473
- const result = pruneMemory(tmpDir, 30, false);
474
- assert.deepEqual(result.stale, []);
475
- });
476
-
477
- it("identifies stale files without removing them (remove=false)", () => {
478
- fs.writeFileSync(
479
- path.join(tmpDir, "memory", "old.md"),
480
- "---\ntype: project\nlast_updated: 2020-01-01\n---\n\n# old",
481
- "utf8"
482
- );
483
- const result = pruneMemory(tmpDir, 30, false);
484
- assert.ok(result.stale.includes("old.md"));
485
- assert.ok(fs.existsSync(path.join(tmpDir, "memory", "old.md")));
486
- });
487
-
488
- it("removes stale files when remove=true", () => {
489
- fs.writeFileSync(
490
- path.join(tmpDir, "memory", "old.md"),
491
- "---\ntype: project\nlast_updated: 2020-01-01\n---\n\n# old",
492
- "utf8"
493
- );
494
- const result = pruneMemory(tmpDir, 30, true);
495
- assert.ok(result.removed.includes("old.md"));
496
- assert.ok(!fs.existsSync(path.join(tmpDir, "memory", "old.md")));
497
- });
498
-
499
- it("never removes MEMORY.md", () => {
500
- fs.writeFileSync(
501
- path.join(tmpDir, "memory", "MEMORY.md"),
502
- "---\ntype: index\nlast_updated: 2020-01-01\n---\n\n# index",
503
- "utf8"
504
- );
505
- pruneMemory(tmpDir, 30, true);
506
- assert.ok(fs.existsSync(path.join(tmpDir, "memory", "MEMORY.md")));
507
- });
508
- });
509
- ```
510
-
511
- - [ ] **Step 2: Run test to confirm it fails**
512
-
513
- ```bash
514
- node --import tsx/esm --test test/memory.test.ts 2>&1 | grep "fail\|Cannot find"
515
- ```
516
-
517
- Expected: `SyntaxError: The requested module '../src/memory.ts' does not provide an export named 'pruneMemory'`
518
-
519
- - [ ] **Step 3: Add exports to `src/memory.ts`**
520
-
521
- Add at the bottom of `src/memory.ts` (before the closing, after `exportMemory`):
522
-
523
- ```typescript
524
- export interface MemoryFileMeta {
525
- file: string;
526
- lastUpdated: Date | null;
527
- daysSinceUpdate: number | null;
528
- }
529
-
530
- export interface PruneResult {
531
- stale: string[];
532
- removed: string[];
533
- }
534
-
535
- export function getMemoryMeta(forgehiveDir: string): MemoryFileMeta[] {
536
- const memDir = path.join(forgehiveDir, "memory");
537
- if (!fs.existsSync(memDir)) return [];
538
- const now = new Date();
539
- return fs.readdirSync(memDir)
540
- .filter(f => f.endsWith(".md") && f !== "MEMORY.md")
541
- .map(f => {
542
- const content = fs.readFileSync(path.join(memDir, f), "utf8");
543
- const match = content.match(/^---\n[\s\S]*?last_updated:\s*(\d{4}-\d{2}-\d{2})[\s\S]*?---/m);
544
- const lastUpdated = match ? new Date(match[1]) : null;
545
- const daysSinceUpdate = lastUpdated && !isNaN(lastUpdated.getTime())
546
- ? Math.floor((now.getTime() - lastUpdated.getTime()) / (1000 * 60 * 60 * 24))
547
- : null;
548
- return { file: f, lastUpdated: lastUpdated && !isNaN(lastUpdated.getTime()) ? lastUpdated : null, daysSinceUpdate };
549
- });
550
- }
551
-
552
- export function pruneMemory(forgehiveDir: string, days: number, remove: boolean): PruneResult {
553
- const meta = getMemoryMeta(forgehiveDir);
554
- const stale = meta
555
- .filter(m => m.daysSinceUpdate !== null && m.daysSinceUpdate > days)
556
- .map(m => m.file);
557
- const removed: string[] = [];
558
- if (remove) {
559
- for (const f of stale) {
560
- fs.unlinkSync(path.join(forgehiveDir, "memory", f));
561
- removed.push(f);
562
- }
563
- }
564
- return { stale, removed };
565
- }
566
- ```
567
-
568
- Also update the memory file stubs in `src/memory.ts` to include frontmatter. Replace `PROJECT_STUB`, `FEEDBACK_STUB`, and `STACK_STUB` constants with functions that include today's date:
569
-
570
- Replace the three constants and the `write` calls in `initMemory` with:
571
-
572
- ```typescript
573
- function makeProjectStub(date: string): string {
574
- return `---
575
- type: project
576
- last_updated: ${date}
577
- confidence: high
578
- ---
579
-
580
- # Projektkontext
581
-
582
- Noch keine Einträge. Claude füllt diese Datei beim ersten Session-Ende.
583
- `;
584
- }
585
-
586
- function makeFeedbackStub(date: string): string {
587
- return `---
588
- type: feedback
589
- last_updated: ${date}
590
- confidence: high
591
- ---
592
-
593
- # Feedback & Korrekturen
594
-
595
- Noch keine Einträge.
596
- `;
597
- }
598
-
599
- function makeStackStub(date: string): string {
600
- return `---
601
- type: reference
602
- last_updated: ${date}
603
- confidence: high
604
- ---
605
-
606
- # Stack-Eigenheiten
607
-
608
- Eigenheiten die der Scanner nicht erkennt — spezifische Konventionen, Workarounds, bekannte Einschränkungen.
609
- `;
610
- }
611
- ```
612
-
613
- Update `initMemory` to use these functions:
614
-
615
- ```typescript
616
- export function initMemory(forgehiveDir: string): void {
617
- const memDir = path.join(forgehiveDir, "memory");
618
- fs.mkdirSync(memDir, { recursive: true });
619
- fs.mkdirSync(path.join(forgehiveDir, "state"), { recursive: true });
620
-
621
- const date = new Date().toISOString().slice(0, 10);
622
- const write = (filename: string, content: string) => {
623
- const p = path.join(memDir, filename);
624
- if (!fs.existsSync(p)) fs.writeFileSync(p, content, "utf8");
625
- };
626
-
627
- write("MEMORY.md", MEMORY_INDEX);
628
- write("project.md", makeProjectStub(date));
629
- write("feedback.md", makeFeedbackStub(date));
630
- write("stack.md", makeStackStub(date));
631
- }
632
- ```
633
-
634
- Remove the old `PROJECT_STUB`, `FEEDBACK_STUB`, `STACK_STUB` constants.
635
-
636
- - [ ] **Step 4: Add `fh memory prune` to `src/cli.ts`**
637
-
638
- In `src/cli.ts`, find the `} else if (subcommand === "export") {` block inside the `memory` handler. Add a new `else if` branch before the final `else`:
639
-
640
- ```typescript
641
- } else if (subcommand === "prune") {
642
- const daysArg = rest[0] ? parseInt(rest[0], 10) : 30;
643
- const doRemove = rest.includes("--remove");
644
- const days = isNaN(daysArg) ? 30 : daysArg;
645
- const { pruneMemory } = await import("./memory.ts");
646
- const result = pruneMemory(forgehiveDir, days, doRemove);
647
- if (result.stale.length === 0) {
648
- console.log(`✓ Kein veraltetes Memory (>${days} Tage) gefunden`);
649
- } else if (!doRemove) {
650
- console.log(`⚠ Veraltete Memory-Dateien (>${days} Tage):`);
651
- for (const f of result.stale) console.log(` ${f}`);
652
- console.log(`\nAusführen mit --remove um zu löschen: fh memory prune ${days} --remove`);
653
- } else {
654
- console.log(`Gelöscht: ${result.removed.join(", ")}`);
655
- }
656
- ```
657
-
658
- Note: `src/cli.ts` does not use dynamic imports — use static import at top instead. Add to existing imports at top of cli.ts:
659
-
660
- ```typescript
661
- import { showMemory, cleanMemory, exportMemory, pruneMemory } from "./memory.ts";
662
- ```
663
-
664
- Then in the handler add the `prune` branch (no dynamic import needed).
665
-
666
- - [ ] **Step 5: Run all tests**
667
-
668
- ```bash
669
- node --import tsx/esm --test test/*.test.ts 2>&1 | tail -8
670
- ```
671
-
672
- Expected:
673
- ```
674
- # pass 140
675
- # fail 0
676
- ```
677
-
678
- - [ ] **Step 6: Commit**
679
-
680
- ```bash
681
- git add src/memory.ts test/memory.test.ts src/cli.ts
682
- git commit -m "feat: memory aging — frontmatter tracking + fh memory prune [days] [--remove]"
683
- ```
684
-
685
- ---
686
-
687
- ### Task 4: ADR Integration
688
-
689
- **Files:**
690
- - Create: `src/adr.ts`
691
- - Modify: `src/cli.ts` (memory adr subcommand)
692
- - Test: `test/adr.test.ts`
693
-
694
- - [ ] **Step 1: Write failing tests**
695
-
696
- ```typescript
697
- // test/adr.test.ts
698
- import { describe, it, beforeEach, afterEach } from "node:test";
699
- import assert from "node:assert/strict";
700
- import fs from "node:fs";
701
- import path from "node:path";
702
- import os from "node:os";
703
- import { createAdr, listAdrs } from "../src/adr.ts";
704
-
705
- describe("createAdr()", () => {
706
- let tmpDir: string;
707
- let forgehiveDir: string;
708
- beforeEach(() => {
709
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "forgehive-adr-"));
710
- forgehiveDir = path.join(tmpDir, ".forgehive");
711
- fs.mkdirSync(path.join(forgehiveDir, "memory"), { recursive: true });
712
- });
713
- afterEach(() => fs.rmSync(tmpDir, { recursive: true, force: true }));
714
-
715
- it("creates adrs/ directory", () => {
716
- createAdr(forgehiveDir, tmpDir, "use typescript over javascript");
717
- assert.ok(fs.existsSync(path.join(forgehiveDir, "memory", "adrs")));
718
- });
719
-
720
- it("creates a markdown file with date-slug name", () => {
721
- const filepath = createAdr(forgehiveDir, tmpDir, "use typescript");
722
- assert.ok(fs.existsSync(filepath));
723
- assert.ok(path.basename(filepath).includes("use-typescript"));
724
- assert.ok(path.basename(filepath).endsWith(".md"));
725
- });
726
-
727
- it("file contains the title and sections", () => {
728
- const filepath = createAdr(forgehiveDir, tmpDir, "use postgres for storage");
729
- const content = fs.readFileSync(filepath, "utf8");
730
- assert.ok(content.includes("use postgres for storage"));
731
- assert.ok(content.includes("## Context"));
732
- assert.ok(content.includes("## Decision"));
733
- assert.ok(content.includes("## Consequences"));
734
- });
735
-
736
- it("file contains current date", () => {
737
- const filepath = createAdr(forgehiveDir, tmpDir, "some decision");
738
- const content = fs.readFileSync(filepath, "utf8");
739
- const today = new Date().toISOString().slice(0, 10);
740
- assert.ok(content.includes(today));
741
- });
742
- });
743
-
744
- describe("listAdrs()", () => {
745
- let tmpDir: string;
746
- let forgehiveDir: string;
747
- beforeEach(() => {
748
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "forgehive-adr-list-"));
749
- forgehiveDir = path.join(tmpDir, ".forgehive");
750
- fs.mkdirSync(path.join(forgehiveDir, "memory"), { recursive: true });
751
- });
752
- afterEach(() => fs.rmSync(tmpDir, { recursive: true, force: true }));
753
-
754
- it("returns empty array when no adrs exist", () => {
755
- assert.deepEqual(listAdrs(forgehiveDir), []);
756
- });
757
-
758
- it("lists created ADRs with title and date", () => {
759
- createAdr(forgehiveDir, tmpDir, "use redis for caching");
760
- const adrs = listAdrs(forgehiveDir);
761
- assert.equal(adrs.length, 1);
762
- assert.ok(adrs[0].title.includes("use redis for caching"));
763
- assert.ok(adrs[0].date.match(/^\d{4}-\d{2}-\d{2}$/));
764
- });
765
- });
766
- ```
767
-
768
- - [ ] **Step 2: Run test to confirm it fails**
769
-
770
- ```bash
771
- node --import tsx/esm --test test/adr.test.ts 2>&1 | head -5
772
- ```
773
-
774
- Expected: `Error: Cannot find module '../src/adr.ts'`
775
-
776
- - [ ] **Step 3: Implement `src/adr.ts`**
777
-
778
- ```typescript
779
- import fs from "node:fs";
780
- import path from "node:path";
781
- import { spawnSync } from "node:child_process";
782
-
783
- export interface AdrEntry {
784
- file: string;
785
- title: string;
786
- date: string;
787
- gitSha: string | null;
788
- }
789
-
790
- function slugify(title: string): string {
791
- return title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 60);
792
- }
793
-
794
- export function createAdr(forgehiveDir: string, projectRoot: string, title: string): string {
795
- const adrsDir = path.join(forgehiveDir, "memory", "adrs");
796
- fs.mkdirSync(adrsDir, { recursive: true });
797
-
798
- const date = new Date().toISOString().slice(0, 10);
799
- const slug = slugify(title);
800
- const filename = `${date}-${slug}.md`;
801
- const filepath = path.join(adrsDir, filename);
802
-
803
- const gitResult = spawnSync("git", ["rev-parse", "--short", "HEAD"], {
804
- cwd: projectRoot,
805
- encoding: "utf8",
806
- });
807
- const gitSha = gitResult.status === 0 ? gitResult.stdout.trim() : null;
808
-
809
- const content = [
810
- `# ADR: ${title}`,
811
- "",
812
- `**Date:** ${date}`,
813
- `**Status:** Proposed`,
814
- gitSha ? `**Git SHA:** ${gitSha}` : null,
815
- "",
816
- "## Context",
817
- "",
818
- "_Describe the situation and problem you are solving._",
819
- "",
820
- "## Decision",
821
- "",
822
- "_State the decision made._",
823
- "",
824
- "## Consequences",
825
- "",
826
- "_What are the positive, negative, and neutral outcomes of this decision?_",
827
- ].filter(l => l !== null).join("\n");
828
-
829
- fs.writeFileSync(filepath, content + "\n", "utf8");
830
- return filepath;
831
- }
832
-
833
- export function listAdrs(forgehiveDir: string): AdrEntry[] {
834
- const adrsDir = path.join(forgehiveDir, "memory", "adrs");
835
- if (!fs.existsSync(adrsDir)) return [];
836
-
837
- return fs.readdirSync(adrsDir)
838
- .filter(f => f.endsWith(".md"))
839
- .sort()
840
- .map(f => {
841
- const content = fs.readFileSync(path.join(adrsDir, f), "utf8");
842
- const titleMatch = content.match(/^# ADR: (.+)$/m);
843
- const dateMatch = content.match(/\*\*Date:\*\* (\d{4}-\d{2}-\d{2})/);
844
- const shaMatch = content.match(/\*\*Git SHA:\*\* (\w+)/);
845
- return {
846
- file: f,
847
- title: titleMatch?.[1] ?? f,
848
- date: dateMatch?.[1] ?? "unknown",
849
- gitSha: shaMatch?.[1] ?? null,
850
- };
851
- });
852
- }
853
- ```
854
-
855
- - [ ] **Step 4: Add `fh memory adr` to `src/cli.ts`**
856
-
857
- Add import at top of `cli.ts`:
858
- ```typescript
859
- import { createAdr, listAdrs } from "./adr.ts";
860
- ```
861
-
862
- In the memory command handler, add a new `else if` branch before the final `else` error branch:
863
-
864
- ```typescript
865
- } else if (subcommand === "adr") {
866
- const adrSub = rest[0];
867
- if (!adrSub || adrSub === "list") {
868
- const adrs = listAdrs(forgehiveDir);
869
- if (adrs.length === 0) {
870
- console.log("Keine ADRs vorhanden. Erstelle einen mit: fh memory adr \"<titel>\"");
871
- } else {
872
- console.log("Architecture Decision Records:\n");
873
- for (const adr of adrs) {
874
- console.log(` ${adr.date} ${adr.title}${adr.gitSha ? ` (${adr.gitSha})` : ""}`);
875
- }
876
- }
877
- } else {
878
- const title = [adrSub, ...rest.slice(1)].join(" ");
879
- try {
880
- const filepath = createAdr(forgehiveDir, projectRoot, title);
881
- console.log(`✓ ADR erstellt: ${filepath}`);
882
- console.log(" Öffne die Datei und fülle Context, Decision und Consequences aus.");
883
- } catch (err) {
884
- console.error(`Fehler: ${(err as Error).message}`);
885
- process.exit(1);
886
- }
887
- }
888
- ```
889
-
890
- Also update the help message at the bottom of `cli.ts` to add `adr` to memory subcommands:
891
- ```
892
- memory [show|clean|export|prune|adr]
893
- ```
894
-
895
- - [ ] **Step 5: Run all tests**
896
-
897
- ```bash
898
- node --import tsx/esm --test test/*.test.ts 2>&1 | tail -8
899
- ```
900
-
901
- Expected: `# pass 148` and `# fail 0`
902
-
903
- - [ ] **Step 6: Commit**
904
-
905
- ```bash
906
- git add src/adr.ts test/adr.test.ts src/cli.ts
907
- git commit -m "feat: ADR integration — fh memory adr \"<title>\" creates structured decision records"
908
- ```
909
-
910
- ---
911
-
912
- ### Task 5: Context Drift Detection
913
-
914
- **Files:**
915
- - Modify: `src/status.ts`
916
- - Test: `test/status.test.ts` (add tests to existing file)
917
-
918
- - [ ] **Step 1: Write failing tests** (append to `test/status.test.ts`)
919
-
920
- ```typescript
921
- import { checkDrift } from "../src/status.ts";
922
- import { spawnSync } from "node:child_process";
923
-
924
- describe("checkDrift()", () => {
925
- let tmpDir: string;
926
- let forgehiveDir: string;
927
- beforeEach(() => {
928
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "fh-drift-"));
929
- forgehiveDir = path.join(tmpDir, ".forgehive");
930
- fs.mkdirSync(forgehiveDir, { recursive: true });
931
- });
932
- afterEach(() => fs.rmSync(tmpDir, { recursive: true, force: true }));
933
-
934
- it("returns isDrifted=false when no scan-result.yaml", () => {
935
- const drift = checkDrift(tmpDir, forgehiveDir);
936
- assert.equal(drift.isDrifted, false);
937
- assert.equal(drift.daysSinceScan, null);
938
- });
939
-
940
- it("returns daysSinceScan > 0 for old scan date", () => {
941
- fs.writeFileSync(
942
- path.join(forgehiveDir, "scan-result.yaml"),
943
- yaml.dump({ scanned_at: "2020-01-01", project_root: tmpDir, signals: [], packages: [] }),
944
- "utf8"
945
- );
946
- const drift = checkDrift(tmpDir, forgehiveDir);
947
- assert.ok((drift.daysSinceScan ?? 0) > 365);
948
- assert.equal(drift.isDrifted, true);
949
- });
950
-
951
- it("returns isDrifted=false for scan today", () => {
952
- const today = new Date().toISOString().slice(0, 10);
953
- fs.writeFileSync(
954
- path.join(forgehiveDir, "scan-result.yaml"),
955
- yaml.dump({ scanned_at: today, project_root: tmpDir, signals: [], packages: [] }),
956
- "utf8"
957
- );
958
- const drift = checkDrift(tmpDir, forgehiveDir);
959
- assert.equal(drift.isDrifted, false);
960
- assert.ok((drift.daysSinceScan ?? 99) < 2);
961
- });
962
- });
963
-
964
- describe("projectStatus() with drift", () => {
965
- let tmpDir: string;
966
- let forgehiveDir: string;
967
- beforeEach(() => {
968
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "fh-status-drift-"));
969
- forgehiveDir = path.join(tmpDir, ".forgehive");
970
- fs.mkdirSync(forgehiveDir, { recursive: true });
971
- });
972
- afterEach(() => fs.rmSync(tmpDir, { recursive: true, force: true }));
973
-
974
- it("zeigt Drift-Warnung bei altem Scan", () => {
975
- fs.writeFileSync(
976
- path.join(forgehiveDir, "scan-result.yaml"),
977
- yaml.dump({ scanned_at: "2020-01-01", project_root: tmpDir, signals: [], packages: [] }),
978
- "utf8"
979
- );
980
- const out = projectStatus(tmpDir, forgehiveDir);
981
- assert.ok(out.includes("Drift") || out.includes("veraltet") || out.includes("fh scan"));
982
- });
983
- });
984
- ```
985
-
986
- - [ ] **Step 2: Run test to confirm it fails**
987
-
988
- ```bash
989
- node --import tsx/esm --test test/status.test.ts 2>&1 | grep "fail\|SyntaxError"
990
- ```
991
-
992
- Expected: `SyntaxError: ... does not provide an export named 'checkDrift'`
993
-
994
- - [ ] **Step 3: Add `checkDrift` to `src/status.ts`**
995
-
996
- Add import at top of `src/status.ts` (after existing imports):
997
- ```typescript
998
- import { spawnSync } from "node:child_process";
999
- ```
1000
-
1001
- Add the `DriftInfo` interface and `checkDrift` function before `projectStatus`:
1002
-
1003
- ```typescript
1004
- export interface DriftInfo {
1005
- daysSinceScan: number | null;
1006
- recentCommits: number;
1007
- isDrifted: boolean;
1008
- }
1009
-
1010
- export function checkDrift(projectRoot: string, forgehiveDir: string): DriftInfo {
1011
- const scanPath = path.join(forgehiveDir, "scan-result.yaml");
1012
- if (!fs.existsSync(scanPath)) {
1013
- return { daysSinceScan: null, recentCommits: 0, isDrifted: false };
1014
- }
1015
-
1016
- let scannedAt: string | null = null;
1017
- try {
1018
- const raw = yaml.load(fs.readFileSync(scanPath, "utf8")) as { scanned_at?: string };
1019
- scannedAt = raw?.scanned_at ?? null;
1020
- } catch { /* ignore */ }
1021
-
1022
- if (!scannedAt) return { daysSinceScan: null, recentCommits: 0, isDrifted: false };
1023
-
1024
- const scanDate = new Date(scannedAt);
1025
- if (isNaN(scanDate.getTime())) return { daysSinceScan: null, recentCommits: 0, isDrifted: false };
1026
-
1027
- const daysSinceScan = Math.floor(
1028
- (Date.now() - scanDate.getTime()) / (1000 * 60 * 60 * 24)
1029
- );
1030
-
1031
- let recentCommits = 0;
1032
- const gitResult = spawnSync("git", ["log", "--oneline", `--since=${scannedAt}T00:00:00`], {
1033
- cwd: projectRoot,
1034
- encoding: "utf8",
1035
- });
1036
- if (gitResult.status === 0) {
1037
- recentCommits = gitResult.stdout.trim().split("\n").filter(Boolean).length;
1038
- }
1039
-
1040
- const isDrifted = daysSinceScan > 7 || recentCommits > 20;
1041
- return { daysSinceScan, recentCommits, isDrifted };
1042
- }
1043
- ```
1044
-
1045
- Add drift reporting to `projectStatus()`, after the last scan line:
1046
-
1047
- ```typescript
1048
- // Context drift
1049
- const drift = checkDrift(projectRoot, forgehiveDir);
1050
- if (drift.isDrifted) {
1051
- const msg = drift.daysSinceScan !== null
1052
- ? `⚠ Kontext veraltet: ${drift.daysSinceScan} Tage seit letztem Scan, ${drift.recentCommits} neue Commits — führe \`fh scan --update\` aus`
1053
- : `⚠ Drift erkannt — führe \`fh scan --update\` aus`;
1054
- lines.push(msg);
1055
- } else if (drift.daysSinceScan !== null) {
1056
- lines.push(`✓ Kontext aktuell (${drift.daysSinceScan} Tage alt, ${drift.recentCommits} Commits seit Scan)`);
1057
- }
1058
- ```
1059
-
1060
- - [ ] **Step 4: Run all tests**
1061
-
1062
- ```bash
1063
- node --import tsx/esm --test test/*.test.ts 2>&1 | tail -8
1064
- ```
1065
-
1066
- Expected: `# pass 153` and `# fail 0`
1067
-
1068
- - [ ] **Step 5: Commit**
1069
-
1070
- ```bash
1071
- git add src/status.ts test/status.test.ts
1072
- git commit -m "feat: context drift detection in fh status — warns when scan is stale"
1073
- ```
1074
-
1075
- ---
1076
-
1077
- ### Task 6: Session Cost Tracking
1078
-
1079
- **Files:**
1080
- - Create: `src/cost.ts`
1081
- - Modify: `src/cli.ts` (add `fh cost` command)
1082
- - Test: `test/cost.test.ts`
1083
-
1084
- - [ ] **Step 1: Write failing tests**
1085
-
1086
- ```typescript
1087
- // test/cost.test.ts
1088
- import { describe, it, beforeEach, afterEach } from "node:test";
1089
- import assert from "node:assert/strict";
1090
- import fs from "node:fs";
1091
- import path from "node:path";
1092
- import os from "node:os";
1093
- import { parseCostSessions, formatCostReport, type SessionCost } from "../src/cost.ts";
1094
-
1095
- describe("parseCostSessions()", () => {
1096
- let tmpDir: string;
1097
- beforeEach(() => {
1098
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "forgehive-cost-"));
1099
- });
1100
- afterEach(() => fs.rmSync(tmpDir, { recursive: true, force: true }));
1101
-
1102
- it("returns empty array when no Claude project dir exists", () => {
1103
- const sessions = parseCostSessions(tmpDir, path.join(tmpDir, "fake-claude-home"));
1104
- assert.deepEqual(sessions, []);
1105
- });
1106
-
1107
- it("parses usage from JSONL file", () => {
1108
- const encoded = "-home-user-myproject";
1109
- const projectDir = path.join(tmpDir, "projects", encoded);
1110
- fs.mkdirSync(projectDir, { recursive: true });
1111
- const lines = [
1112
- JSON.stringify({ usage: { input_tokens: 1000, output_tokens: 500 } }),
1113
- JSON.stringify({ usage: { input_tokens: 2000, output_tokens: 1000, cache_read_input_tokens: 500 } }),
1114
- '{"no_usage": true}',
1115
- ].join("\n");
1116
- fs.writeFileSync(path.join(projectDir, "session-abc.jsonl"), lines, "utf8");
1117
-
1118
- const sessions = parseCostSessions(tmpDir, tmpDir);
1119
- assert.equal(sessions.length, 1);
1120
- assert.equal(sessions[0].inputTokens, 3000);
1121
- assert.equal(sessions[0].outputTokens, 1500);
1122
- assert.ok(sessions[0].estimatedCostUsd > 0);
1123
- });
1124
-
1125
- it("skips malformed JSONL lines", () => {
1126
- const encoded = "-home-user-myproject";
1127
- const projectDir = path.join(tmpDir, "projects", encoded);
1128
- fs.mkdirSync(projectDir, { recursive: true });
1129
- fs.writeFileSync(
1130
- path.join(projectDir, "session.jsonl"),
1131
- 'not json\n{"usage": {"input_tokens": 100, "output_tokens": 50}}\n{broken',
1132
- "utf8"
1133
- );
1134
- const sessions = parseCostSessions(tmpDir, tmpDir);
1135
- assert.equal(sessions.length, 1);
1136
- assert.equal(sessions[0].inputTokens, 100);
1137
- });
1138
- });
1139
-
1140
- describe("formatCostReport()", () => {
1141
- it("shows 'Keine Sessions' when empty", () => {
1142
- const report = formatCostReport([], "all");
1143
- assert.ok(report.includes("Keine Sessions"));
1144
- });
1145
-
1146
- it("shows total cost and session count", () => {
1147
- const sessions: SessionCost[] = [{
1148
- sessionFile: "abc.jsonl",
1149
- date: "2026-05-11",
1150
- inputTokens: 1_000_000,
1151
- outputTokens: 100_000,
1152
- cacheCreationTokens: 0,
1153
- cacheReadTokens: 0,
1154
- estimatedCostUsd: 4.50,
1155
- }];
1156
- const report = formatCostReport(sessions, "all");
1157
- assert.ok(report.includes("4.500") || report.includes("$4.5"));
1158
- assert.ok(report.includes("1 session"));
1159
- });
1160
- });
1161
- ```
1162
-
1163
- - [ ] **Step 2: Run test to confirm it fails**
1164
-
1165
- ```bash
1166
- node --import tsx/esm --test test/cost.test.ts 2>&1 | head -5
1167
- ```
1168
-
1169
- Expected: `Error: Cannot find module '../src/cost.ts'`
1170
-
1171
- - [ ] **Step 3: Implement `src/cost.ts`**
1172
-
1173
- ```typescript
1174
- import fs from "node:fs";
1175
- import path from "node:path";
1176
- import os from "node:os";
1177
-
1178
- export interface SessionCost {
1179
- sessionFile: string;
1180
- date: string;
1181
- inputTokens: number;
1182
- outputTokens: number;
1183
- cacheCreationTokens: number;
1184
- cacheReadTokens: number;
1185
- estimatedCostUsd: number;
1186
- }
1187
-
1188
- // Claude Sonnet 4.6 pricing per million tokens (May 2026)
1189
- const PRICING = {
1190
- input: 3.0,
1191
- output: 15.0,
1192
- cacheCreation: 3.75,
1193
- cacheRead: 0.30,
1194
- };
1195
-
1196
- function calcCost(
1197
- inputTokens: number,
1198
- outputTokens: number,
1199
- cacheCreationTokens: number,
1200
- cacheReadTokens: number
1201
- ): number {
1202
- return (
1203
- (inputTokens / 1_000_000) * PRICING.input +
1204
- (outputTokens / 1_000_000) * PRICING.output +
1205
- (cacheCreationTokens / 1_000_000) * PRICING.cacheCreation +
1206
- (cacheReadTokens / 1_000_000) * PRICING.cacheRead
1207
- );
1208
- }
1209
-
1210
- interface UsageLine {
1211
- usage?: {
1212
- input_tokens?: number;
1213
- output_tokens?: number;
1214
- cache_creation_input_tokens?: number;
1215
- cache_read_input_tokens?: number;
1216
- };
1217
- }
1218
-
1219
- // claudeHome defaults to ~/.claude; override in tests
1220
- export function parseCostSessions(
1221
- projectRoot: string,
1222
- claudeHome: string = path.join(os.homedir(), ".claude")
1223
- ): SessionCost[] {
1224
- const projectsDir = path.join(claudeHome, "projects");
1225
- if (!fs.existsSync(projectsDir)) return [];
1226
-
1227
- // Find the directory matching this project's encoded path
1228
- const encodedPath = "-" + projectRoot.replace(/\//g, "-");
1229
- const allDirs = fs.readdirSync(projectsDir);
1230
- const matchDirs = allDirs.filter(
1231
- d => d === encodedPath || d.endsWith("-" + path.basename(projectRoot))
1232
- );
1233
-
1234
- const sessions: SessionCost[] = [];
1235
-
1236
- for (const dir of matchDirs) {
1237
- const dirPath = path.join(projectsDir, dir);
1238
- if (!fs.statSync(dirPath).isDirectory()) continue;
1239
-
1240
- for (const jsonlFile of fs.readdirSync(dirPath).filter(f => f.endsWith(".jsonl"))) {
1241
- const filePath = path.join(dirPath, jsonlFile);
1242
- let inputTokens = 0, outputTokens = 0, cacheCreationTokens = 0, cacheReadTokens = 0;
1243
-
1244
- try {
1245
- for (const line of fs.readFileSync(filePath, "utf8").split("\n").filter(Boolean)) {
1246
- try {
1247
- const entry = JSON.parse(line) as UsageLine;
1248
- if (entry.usage) {
1249
- inputTokens += entry.usage.input_tokens ?? 0;
1250
- outputTokens += entry.usage.output_tokens ?? 0;
1251
- cacheCreationTokens += entry.usage.cache_creation_input_tokens ?? 0;
1252
- cacheReadTokens += entry.usage.cache_read_input_tokens ?? 0;
1253
- }
1254
- } catch { /* skip malformed */ }
1255
- }
1256
-
1257
- const mtime = fs.statSync(filePath).mtime;
1258
- sessions.push({
1259
- sessionFile: jsonlFile,
1260
- date: mtime.toISOString().slice(0, 10),
1261
- inputTokens,
1262
- outputTokens,
1263
- cacheCreationTokens,
1264
- cacheReadTokens,
1265
- estimatedCostUsd: calcCost(inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens),
1266
- });
1267
- } catch { /* skip unreadable */ }
1268
- }
1269
- }
1270
-
1271
- return sessions.sort((a, b) => b.date.localeCompare(a.date));
1272
- }
1273
-
1274
- export function formatCostReport(
1275
- sessions: SessionCost[],
1276
- range: "today" | "week" | "all"
1277
- ): string {
1278
- const now = new Date();
1279
- const today = now.toISOString().slice(0, 10);
1280
- const weekAgo = new Date(now);
1281
- weekAgo.setDate(weekAgo.getDate() - 7);
1282
-
1283
- const filtered = sessions.filter(s => {
1284
- if (range === "today") return s.date === today;
1285
- if (range === "week") return new Date(s.date) >= weekAgo;
1286
- return true;
1287
- });
1288
-
1289
- const lines = [`ForgeHive Cost Report (${range})`, ""];
1290
-
1291
- if (filtered.length === 0) {
1292
- lines.push("Keine Sessions gefunden.");
1293
- return lines.join("\n");
1294
- }
1295
-
1296
- const total = filtered.reduce((sum, s) => sum + s.estimatedCostUsd, 0);
1297
- const totalIn = filtered.reduce((sum, s) => sum + s.inputTokens, 0);
1298
- const totalOut = filtered.reduce((sum, s) => sum + s.outputTokens, 0);
1299
-
1300
- lines.push(`Total: $${total.toFixed(3)} (${filtered.length} session${filtered.length === 1 ? "" : "s"})`);
1301
- lines.push(`Tokens: ${(totalIn / 1000).toFixed(0)}k in, ${(totalOut / 1000).toFixed(0)}k out`);
1302
- lines.push("");
1303
- lines.push("Sessions:");
1304
-
1305
- for (const s of filtered.slice(0, 15)) {
1306
- lines.push(` ${s.date} $${s.estimatedCostUsd.toFixed(3).padStart(7)} ${s.sessionFile.slice(0, 36)}`);
1307
- }
1308
-
1309
- if (filtered.length > 15) {
1310
- lines.push(` ... und ${filtered.length - 15} weitere`);
1311
- }
1312
-
1313
- return lines.join("\n");
1314
- }
1315
- ```
1316
-
1317
- - [ ] **Step 4: Add `fh cost` to `src/cli.ts`**
1318
-
1319
- Add import at top:
1320
- ```typescript
1321
- import { parseCostSessions, formatCostReport } from "./cost.ts";
1322
- ```
1323
-
1324
- Add new command handler before the final `else` block:
1325
-
1326
- ```typescript
1327
- } else if (command === "cost") {
1328
- const range = (subcommand as "today" | "week" | "all") ?? "all";
1329
- if (!["today", "week", "all"].includes(range)) {
1330
- console.error(`Unbekannter Bereich: ${range}. Verfügbar: today | week | all`);
1331
- process.exit(1);
1332
- }
1333
- const sessions = parseCostSessions(projectRoot);
1334
- console.log(formatCostReport(sessions, range));
1335
-
1336
- ```
1337
-
1338
- Update help message to add `cost [today|week|all]`.
1339
-
1340
- - [ ] **Step 5: Run all tests**
1341
-
1342
- ```bash
1343
- node --import tsx/esm --test test/*.test.ts 2>&1 | tail -8
1344
- ```
1345
-
1346
- Expected: `# pass 160` and `# fail 0`
1347
-
1348
- - [ ] **Step 6: Commit**
1349
-
1350
- ```bash
1351
- git add src/cost.ts test/cost.test.ts src/cli.ts
1352
- git commit -m "feat: session cost tracking — fh cost [today|week|all] parses Claude JSONL logs"
1353
- ```
1354
-
1355
- ---
1356
-
1357
- ### Task 7: Multi-Model Routing for Party
1358
-
1359
- **Files:**
1360
- - Modify: `src/party.ts`
1361
- - Test: `test/party.test.ts` (add tests to existing file)
1362
-
1363
- - [ ] **Step 1: Write failing tests** (append to `test/party.test.ts`)
1364
-
1365
- ```typescript
1366
- import { setAgentModel, showParty } from "../src/party.ts";
1367
-
1368
- describe("setAgentModel()", () => {
1369
- let tmpDir: string;
1370
- let forgehiveDir: string;
1371
- beforeEach(() => {
1372
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "forgehive-model-"));
1373
- forgehiveDir = path.join(tmpDir, ".forgehive");
1374
- fs.mkdirSync(path.join(forgehiveDir, "party"), { recursive: true });
1375
- fs.writeFileSync(
1376
- path.join(forgehiveDir, "party", "config.yaml"),
1377
- yaml.dump({
1378
- sets: {
1379
- build: { agents: ["viktor", "kai"], trigger: "/party", description: "build set" }
1380
- }
1381
- }),
1382
- "utf8"
1383
- );
1384
- });
1385
- afterEach(() => fs.rmSync(tmpDir, { recursive: true, force: true }));
1386
-
1387
- it("sets model_map for an agent", () => {
1388
- setAgentModel(forgehiveDir, "build", "viktor", "claude-opus-4-7");
1389
- const config = yaml.load(
1390
- fs.readFileSync(path.join(forgehiveDir, "party", "config.yaml"), "utf8")
1391
- ) as { sets: { build: { model_map?: Record<string, string> } } };
1392
- assert.equal(config.sets.build.model_map?.["viktor"], "claude-opus-4-7");
1393
- });
1394
-
1395
- it("throws for unknown set", () => {
1396
- assert.throws(
1397
- () => setAgentModel(forgehiveDir, "unknown-set", "agent", "model"),
1398
- /unknown-set/
1399
- );
1400
- });
1401
-
1402
- it("showParty includes model_map when set", () => {
1403
- setAgentModel(forgehiveDir, "build", "viktor", "claude-opus-4-7");
1404
- const output = showParty(forgehiveDir);
1405
- assert.ok(output.includes("claude-opus-4-7") || output.includes("model"));
1406
- });
1407
- });
1408
- ```
1409
-
1410
- - [ ] **Step 2: Run test to confirm it fails**
1411
-
1412
- ```bash
1413
- node --import tsx/esm --test test/party.test.ts 2>&1 | grep "fail\|SyntaxError"
1414
- ```
1415
-
1416
- Expected: `SyntaxError: ... does not provide an export named 'setAgentModel'`
1417
-
1418
- - [ ] **Step 3: Update `src/party.ts`**
1419
-
1420
- Update the `PartySet` interface to include `model_map`:
1421
-
1422
- ```typescript
1423
- interface PartySet {
1424
- agents: string[];
1425
- trigger: string;
1426
- description: string;
1427
- model_map?: Record<string, string>;
1428
- }
1429
- ```
1430
-
1431
- Add `setAgentModel` export after `addPartySet`:
1432
-
1433
- ```typescript
1434
- export function setAgentModel(
1435
- forgehiveDir: string,
1436
- setName: string,
1437
- agentName: string,
1438
- modelId: string
1439
- ): void {
1440
- const config = loadConfig(forgehiveDir);
1441
- if (!config.sets[setName]) {
1442
- throw new Error(
1443
- `Unbekanntes Set: "${setName}". Verfügbar: ${Object.keys(config.sets).join(", ")}`
1444
- );
1445
- }
1446
- if (!config.sets[setName].model_map) config.sets[setName].model_map = {};
1447
- config.sets[setName].model_map![agentName] = modelId;
1448
- saveConfig(forgehiveDir, config);
1449
- }
1450
- ```
1451
-
1452
- Update `showParty` to display model_map when present. Replace the loop body:
1453
-
1454
- ```typescript
1455
- for (const [name, set] of Object.entries(config.sets)) {
1456
- const marker = name === config.default_set ? " ← default" : "";
1457
- lines.push(` ${name.padEnd(12)} ${set.trigger.padEnd(20)} Agenten: ${set.agents.join(", ")}${marker}`);
1458
- if (set.model_map && Object.keys(set.model_map).length > 0) {
1459
- const mapStr = Object.entries(set.model_map).map(([a, m]) => `${a}:${m}`).join(", ");
1460
- lines.push(` ${"".padEnd(12)} ${"".padEnd(20)} Modelle: ${mapStr}`);
1461
- }
1462
- }
1463
- ```
1464
-
1465
- - [ ] **Step 4: Add `fh party --model-map` to `src/cli.ts`**
1466
-
1467
- Add import at top:
1468
- ```typescript
1469
- import { showParty, setPartyDefault, addPartySet, setAgentModel } from "./party.ts";
1470
- ```
1471
-
1472
- In the existing `party` command handler, add a `modelFlag` branch. Inside the party args parsing block (after the `agentsFlag` block), add:
1473
-
1474
- ```typescript
1475
- const modelMapFlag = partyArgs.findIndex(a => a === "--model-map");
1476
-
1477
- // ... existing setFlag and agentsFlag handling ...
1478
-
1479
- } else if (modelMapFlag !== -1) {
1480
- const mapArg = partyArgs[modelMapFlag + 1];
1481
- if (!mapArg) {
1482
- console.error("Fehler: --model-map erwartet ein Mapping wie \"architect:opus,code:sonnet\"");
1483
- process.exit(1);
1484
- }
1485
- const setName = partyArgs[setFlag + 1] ?? (
1486
- yaml.load(fs.readFileSync(path.join(forgehiveDir, "party", "config.yaml"), "utf8")) as { default_set?: string }
1487
- )?.default_set ?? "default";
1488
- for (const pair of mapArg.split(",")) {
1489
- const [agent, model] = pair.split(":").map(s => s.trim());
1490
- if (!agent || !model) continue;
1491
- try {
1492
- setAgentModel(forgehiveDir, setName, agent, model);
1493
- console.log(`✓ ${agent} → ${model}`);
1494
- } catch (err) {
1495
- console.error(`Fehler: ${(err as Error).message}`);
1496
- process.exit(1);
1497
- }
1498
- }
1499
- ```
1500
-
1501
- - [ ] **Step 5: Run all tests**
1502
-
1503
- ```bash
1504
- node --import tsx/esm --test test/*.test.ts 2>&1 | tail -8
1505
- ```
1506
-
1507
- Expected: `# pass 164` and `# fail 0`
1508
-
1509
- - [ ] **Step 6: Commit**
1510
-
1511
- ```bash
1512
- git add src/party.ts test/party.test.ts src/cli.ts
1513
- git commit -m "feat: multi-model routing for party — fh party --model-map \"architect:opus,code:sonnet\""
1514
- ```
1515
-
1516
- ---
1517
-
1518
- ### Task 8: Worktree-Isolated Party Runs
1519
-
1520
- **Files:**
1521
- - Modify: `src/party.ts` (add runParty, getPartyStatus, cleanupPartyWorktrees)
1522
- - Modify: `src/cli.ts` (add `fh party run/status/cleanup`)
1523
- - Test: `test/party.test.ts` (add tests)
1524
-
1525
- - [ ] **Step 1: Write failing tests** (append to `test/party.test.ts`)
1526
-
1527
- ```typescript
1528
- import { runParty, getPartyStatus, cleanupPartyWorktrees } from "../src/party.ts";
1529
- import { spawnSync as _spawnSync } from "node:child_process";
1530
-
1531
- describe("runParty()", () => {
1532
- let tmpDir: string;
1533
- let forgehiveDir: string;
1534
- beforeEach(() => {
1535
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "forgehive-run-party-"));
1536
- forgehiveDir = path.join(tmpDir, ".forgehive");
1537
- fs.mkdirSync(path.join(forgehiveDir, "party"), { recursive: true });
1538
- fs.writeFileSync(
1539
- path.join(forgehiveDir, "party", "config.yaml"),
1540
- yaml.dump({
1541
- sets: {
1542
- build: { agents: ["viktor", "kai"], trigger: "/party", description: "build" }
1543
- }
1544
- }),
1545
- "utf8"
1546
- );
1547
- // Init git repo so worktree add works
1548
- _spawnSync("git", ["init"], { cwd: tmpDir });
1549
- _spawnSync("git", ["commit", "--allow-empty", "-m", "init"], { cwd: tmpDir });
1550
- });
1551
- afterEach(() => {
1552
- // Cleanup any worktrees before removing tmpDir
1553
- _spawnSync("git", ["worktree", "prune"], { cwd: tmpDir });
1554
- fs.rmSync(tmpDir, { recursive: true, force: true });
1555
- });
1556
-
1557
- it("throws for unknown set", () => {
1558
- assert.throws(
1559
- () => runParty(forgehiveDir, tmpDir, "unknown"),
1560
- /unknown/
1561
- );
1562
- });
1563
-
1564
- it("creates worktrees dir under .forgehive/", () => {
1565
- runParty(forgehiveDir, tmpDir, "build");
1566
- assert.ok(fs.existsSync(path.join(forgehiveDir, "worktrees")));
1567
- });
1568
-
1569
- it("creates one worktree per agent", () => {
1570
- const state = runParty(forgehiveDir, tmpDir, "build");
1571
- assert.equal(state.worktrees.length, 2);
1572
- for (const wt of state.worktrees) {
1573
- assert.ok(fs.existsSync(wt.path));
1574
- }
1575
- });
1576
-
1577
- it("writes dispatch file in each worktree", () => {
1578
- const state = runParty(forgehiveDir, tmpDir, "build");
1579
- for (const wt of state.worktrees) {
1580
- assert.ok(fs.existsSync(path.join(wt.path, ".forgehive-agent-dispatch.md")));
1581
- }
1582
- });
1583
-
1584
- it("saves party-session.yaml state file", () => {
1585
- runParty(forgehiveDir, tmpDir, "build");
1586
- assert.ok(fs.existsSync(path.join(forgehiveDir, "party", "party-session.yaml")));
1587
- });
1588
- });
1589
-
1590
- describe("getPartyStatus()", () => {
1591
- let tmpDir: string;
1592
- let forgehiveDir: string;
1593
- beforeEach(() => {
1594
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "fh-party-status-"));
1595
- forgehiveDir = path.join(tmpDir, ".forgehive");
1596
- fs.mkdirSync(path.join(forgehiveDir, "party"), { recursive: true });
1597
- });
1598
- afterEach(() => fs.rmSync(tmpDir, { recursive: true, force: true }));
1599
-
1600
- it("returns null when no session file", () => {
1601
- assert.equal(getPartyStatus(forgehiveDir), null);
1602
- });
1603
-
1604
- it("reads state from session file", () => {
1605
- const state = { setName: "build", sessionId: "20260511T120000", worktrees: [] };
1606
- fs.writeFileSync(
1607
- path.join(forgehiveDir, "party", "party-session.yaml"),
1608
- yaml.dump(state),
1609
- "utf8"
1610
- );
1611
- const loaded = getPartyStatus(forgehiveDir);
1612
- assert.equal(loaded?.setName, "build");
1613
- });
1614
- });
1615
- ```
1616
-
1617
- - [ ] **Step 2: Run test to confirm it fails**
1618
-
1619
- ```bash
1620
- node --import tsx/esm --test test/party.test.ts 2>&1 | grep "fail\|SyntaxError" | head -3
1621
- ```
1622
-
1623
- Expected: `SyntaxError: ... does not provide an export named 'runParty'`
1624
-
1625
- - [ ] **Step 3: Add worktree functions to `src/party.ts`**
1626
-
1627
- Add import at top of `src/party.ts`:
1628
- ```typescript
1629
- import { spawnSync } from "node:child_process";
1630
- ```
1631
-
1632
- Add these exports after `addPartySet`:
1633
-
1634
- ```typescript
1635
- export interface WorktreeEntry {
1636
- agent: string;
1637
- branch: string;
1638
- path: string;
1639
- status: "pending" | "running" | "done";
1640
- }
1641
-
1642
- export interface WorktreePartyState {
1643
- setName: string;
1644
- sessionId: string;
1645
- worktrees: WorktreeEntry[];
1646
- }
1647
-
1648
- export function runParty(
1649
- forgehiveDir: string,
1650
- projectRoot: string,
1651
- setName: string
1652
- ): WorktreePartyState {
1653
- const config = loadConfig(forgehiveDir);
1654
- if (!config.sets[setName]) {
1655
- throw new Error(
1656
- `Unbekanntes Set: "${setName}". Verfügbar: ${Object.keys(config.sets).join(", ")}`
1657
- );
1658
- }
1659
-
1660
- const sessionId = new Date().toISOString().slice(0, 19).replace(/[:-]/g, "");
1661
- const wtBaseDir = path.join(forgehiveDir, "worktrees");
1662
- fs.mkdirSync(wtBaseDir, { recursive: true });
1663
-
1664
- const state: WorktreePartyState = { setName, sessionId, worktrees: [] };
1665
-
1666
- for (const agent of config.sets[setName].agents) {
1667
- const branch = `forgehive/party/${sessionId}/${agent}`;
1668
- const wtPath = path.join(wtBaseDir, `${sessionId}-${agent}`);
1669
-
1670
- const result = spawnSync("git", ["worktree", "add", "-b", branch, wtPath], {
1671
- cwd: projectRoot,
1672
- encoding: "utf8",
1673
- });
1674
-
1675
- if (result.status !== 0) {
1676
- throw new Error(`Worktree für ${agent} fehlgeschlagen: ${result.stderr?.trim()}`);
1677
- }
1678
-
1679
- fs.writeFileSync(
1680
- path.join(wtPath, ".forgehive-agent-dispatch.md"),
1681
- [
1682
- `# ${agent} — Party Session ${sessionId}`,
1683
- "",
1684
- `Dieser Worktree ist für Agent **${agent}** im Set **${setName}**.`,
1685
- "",
1686
- `Branch: \`${branch}\``,
1687
- "",
1688
- "## Aufgabe",
1689
- "",
1690
- "_Trage hier die spezifische Aufgabe für diesen Agenten ein._",
1691
- "",
1692
- "Führe \`claude\` in diesem Verzeichnis aus um die Arbeit zu starten.",
1693
- ].join("\n") + "\n",
1694
- "utf8"
1695
- );
1696
-
1697
- state.worktrees.push({ agent, branch, path: wtPath, status: "pending" });
1698
- }
1699
-
1700
- fs.writeFileSync(
1701
- path.join(forgehiveDir, "party", "party-session.yaml"),
1702
- yaml.dump(state),
1703
- "utf8"
1704
- );
1705
-
1706
- return state;
1707
- }
1708
-
1709
- export function getPartyStatus(forgehiveDir: string): WorktreePartyState | null {
1710
- const statePath = path.join(forgehiveDir, "party", "party-session.yaml");
1711
- if (!fs.existsSync(statePath)) return null;
1712
- try {
1713
- return yaml.load(fs.readFileSync(statePath, "utf8")) as WorktreePartyState;
1714
- } catch {
1715
- return null;
1716
- }
1717
- }
1718
-
1719
- export function cleanupPartyWorktrees(
1720
- forgehiveDir: string,
1721
- projectRoot: string
1722
- ): string[] {
1723
- const state = getPartyStatus(forgehiveDir);
1724
- if (!state) throw new Error("Keine aktive Party-Session gefunden");
1725
-
1726
- const removed: string[] = [];
1727
- for (const wt of state.worktrees) {
1728
- if (fs.existsSync(wt.path)) {
1729
- const result = spawnSync("git", ["worktree", "remove", "--force", wt.path], {
1730
- cwd: projectRoot,
1731
- encoding: "utf8",
1732
- });
1733
- if (result.status === 0) removed.push(wt.agent);
1734
- }
1735
- }
1736
-
1737
- spawnSync("git", ["worktree", "prune"], { cwd: projectRoot, encoding: "utf8" });
1738
-
1739
- const statePath = path.join(forgehiveDir, "party", "party-session.yaml");
1740
- if (fs.existsSync(statePath)) fs.unlinkSync(statePath);
1741
-
1742
- return removed;
1743
- }
1744
- ```
1745
-
1746
- - [ ] **Step 4: Add `fh party run/status/cleanup` to `src/cli.ts`**
1747
-
1748
- Update the `party` import:
1749
- ```typescript
1750
- import { showParty, setPartyDefault, addPartySet, setAgentModel, runParty, getPartyStatus, cleanupPartyWorktrees } from "./party.ts";
1751
- ```
1752
-
1753
- In the party handler, add a `run` branch at the top of the args parsing, before `setFlag`:
1754
-
1755
- ```typescript
1756
- if (subcommand === "run") {
1757
- const runSet = rest[0] ?? (() => {
1758
- try {
1759
- return (yaml.load(fs.readFileSync(
1760
- path.join(forgehiveDir, "party", "config.yaml"), "utf8"
1761
- )) as { default_set?: string })?.default_set ?? "default";
1762
- } catch { return "default"; }
1763
- })();
1764
- try {
1765
- const state = runParty(forgehiveDir, projectRoot, runSet);
1766
- console.log(`✓ Party gestartet — Set: ${state.setName}, Session: ${state.sessionId}\n`);
1767
- for (const wt of state.worktrees) {
1768
- console.log(` ${wt.agent.padEnd(12)} Branch: ${wt.branch}`);
1769
- console.log(` ${"".padEnd(12)} Pfad: ${wt.path}`);
1770
- console.log(` ${"".padEnd(12)} Starte: cd "${wt.path}" && claude\n`);
1771
- }
1772
- } catch (err) {
1773
- console.error(`Fehler: ${(err as Error).message}`);
1774
- process.exit(1);
1775
- }
1776
- } else if (subcommand === "status") {
1777
- const state = getPartyStatus(forgehiveDir);
1778
- if (!state) {
1779
- console.log("Keine aktive Party-Session. Starte mit: fh party run");
1780
- } else {
1781
- console.log(`Aktive Session: ${state.sessionId} (Set: ${state.setName})\n`);
1782
- for (const wt of state.worktrees) {
1783
- console.log(` ${wt.agent.padEnd(12)} ${wt.branch}`);
1784
- }
1785
- }
1786
- } else if (subcommand === "cleanup") {
1787
- try {
1788
- const removed = cleanupPartyWorktrees(forgehiveDir, projectRoot);
1789
- console.log(removed.length > 0
1790
- ? `✓ Worktrees entfernt: ${removed.join(", ")}`
1791
- : "Keine Worktrees zu bereinigen"
1792
- );
1793
- } catch (err) {
1794
- console.error(`Fehler: ${(err as Error).message}`);
1795
- process.exit(1);
1796
- }
1797
- } else if (setFlag !== -1) {
1798
- ```
1799
-
1800
- (Note: restructure the `party` handler so `run`, `status`, and `cleanup` are checked first via `subcommand`, then the `partyArgs` flag-based handling follows for the `--set`, `--agents`, `--model-map` flags.)
1801
-
1802
- - [ ] **Step 5: Run all tests**
1803
-
1804
- ```bash
1805
- node --import tsx/esm --test test/*.test.ts 2>&1 | tail -8
1806
- ```
1807
-
1808
- Expected: `# pass 172` and `# fail 0`
1809
-
1810
- - [ ] **Step 6: Commit**
1811
-
1812
- ```bash
1813
- git add src/party.ts test/party.test.ts src/cli.ts
1814
- git commit -m "feat: worktree-isolated party runs — fh party run creates per-agent git worktrees"
1815
- ```
1816
-
1817
- ---
1818
-
1819
- ### Task 9: MCP Credential Management
1820
-
1821
- **Files:**
1822
- - Create: `src/mcp-auth.ts`
1823
- - Modify: `src/cli.ts` (add `fh mcp auth` subcommands)
1824
- - Test: `test/mcp-auth.test.ts`
1825
-
1826
- - [ ] **Step 1: Write failing tests**
1827
-
1828
- ```typescript
1829
- // test/mcp-auth.test.ts
1830
- import { describe, it, beforeEach, afterEach } from "node:test";
1831
- import assert from "node:assert/strict";
1832
- import fs from "node:fs";
1833
- import path from "node:path";
1834
- import os from "node:os";
1835
- import {
1836
- setCredentials, getCredentials, listCredentialServices, removeCredentials
1837
- } from "../src/mcp-auth.ts";
1838
-
1839
- describe("mcp-auth credential store", () => {
1840
- let tmpHome: string;
1841
- const origHome = process.env.HOME;
1842
-
1843
- beforeEach(() => {
1844
- tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "forgehive-mcp-auth-"));
1845
- process.env.HOME = tmpHome;
1846
- });
1847
- afterEach(() => {
1848
- process.env.HOME = origHome;
1849
- fs.rmSync(tmpHome, { recursive: true, force: true });
1850
- });
1851
-
1852
- it("stores and retrieves credentials for a service", () => {
1853
- setCredentials("github", { GITHUB_TOKEN: "ghp_abc123" });
1854
- const creds = getCredentials("github");
1855
- assert.equal(creds?.["GITHUB_TOKEN"], "ghp_abc123");
1856
- });
1857
-
1858
- it("returns null for unknown service", () => {
1859
- assert.equal(getCredentials("unknown-service"), null);
1860
- });
1861
-
1862
- it("merges credentials for the same service", () => {
1863
- setCredentials("slack", { SLACK_BOT_TOKEN: "xoxb-123" });
1864
- setCredentials("slack", { SLACK_TEAM_ID: "T123" });
1865
- const creds = getCredentials("slack");
1866
- assert.equal(creds?.["SLACK_BOT_TOKEN"], "xoxb-123");
1867
- assert.equal(creds?.["SLACK_TEAM_ID"], "T123");
1868
- });
1869
-
1870
- it("lists all services with stored credentials", () => {
1871
- setCredentials("linear", { LINEAR_API_KEY: "lin_abc" });
1872
- setCredentials("github", { GITHUB_TOKEN: "ghp_xyz" });
1873
- const services = listCredentialServices();
1874
- assert.ok(services.includes("linear"));
1875
- assert.ok(services.includes("github"));
1876
- });
1877
-
1878
- it("removes credentials for a service", () => {
1879
- setCredentials("github", { GITHUB_TOKEN: "ghp_abc" });
1880
- removeCredentials("github");
1881
- assert.equal(getCredentials("github"), null);
1882
- });
1883
-
1884
- it("credentials file has mode 600 (user-only)", () => {
1885
- setCredentials("test", { KEY: "value" });
1886
- const credsPath = path.join(tmpHome, ".forgehive", "credentials.json");
1887
- const mode = fs.statSync(credsPath).mode;
1888
- // On Linux: 0o100600 = regular file + 600 perms
1889
- assert.ok((mode & 0o777) === 0o600);
1890
- });
1891
- });
1892
- ```
1893
-
1894
- - [ ] **Step 2: Run test to confirm it fails**
1895
-
1896
- ```bash
1897
- node --import tsx/esm --test test/mcp-auth.test.ts 2>&1 | head -5
1898
- ```
1899
-
1900
- Expected: `Error: Cannot find module '../src/mcp-auth.ts'`
1901
-
1902
- - [ ] **Step 3: Implement `src/mcp-auth.ts`**
1903
-
1904
- ```typescript
1905
- import fs from "node:fs";
1906
- import path from "node:path";
1907
- import os from "node:os";
1908
-
1909
- interface CredentialStore {
1910
- [service: string]: Record<string, string>;
1911
- }
1912
-
1913
- function getCredsPath(): string {
1914
- const dir = path.join(os.homedir(), ".forgehive");
1915
- fs.mkdirSync(dir, { recursive: true });
1916
- return path.join(dir, "credentials.json");
1917
- }
1918
-
1919
- function loadStore(): CredentialStore {
1920
- const p = getCredsPath();
1921
- if (!fs.existsSync(p)) return {};
1922
- try {
1923
- return JSON.parse(fs.readFileSync(p, "utf8")) as CredentialStore;
1924
- } catch {
1925
- return {};
1926
- }
1927
- }
1928
-
1929
- function saveStore(store: CredentialStore): void {
1930
- const p = getCredsPath();
1931
- fs.writeFileSync(p, JSON.stringify(store, null, 2), "utf8");
1932
- try { fs.chmodSync(p, 0o600); } catch { /* ignore on systems without chmod */ }
1933
- }
1934
-
1935
- export function setCredentials(service: string, creds: Record<string, string>): void {
1936
- const store = loadStore();
1937
- store[service] = { ...(store[service] ?? {}), ...creds };
1938
- saveStore(store);
1939
- }
1940
-
1941
- export function getCredentials(service: string): Record<string, string> | null {
1942
- return loadStore()[service] ?? null;
1943
- }
1944
-
1945
- export function listCredentialServices(): string[] {
1946
- return Object.keys(loadStore());
1947
- }
1948
-
1949
- export function removeCredentials(service: string): void {
1950
- const store = loadStore();
1951
- delete store[service];
1952
- saveStore(store);
1953
- }
1954
- ```
1955
-
1956
- - [ ] **Step 4: Add `fh mcp auth` to `src/cli.ts`**
1957
-
1958
- Add import at top:
1959
- ```typescript
1960
- import { setCredentials, getCredentials, listCredentialServices, removeCredentials } from "./mcp-auth.ts";
1961
- ```
1962
-
1963
- Add a new `mcp` command handler before the final `else` block:
1964
-
1965
- ```typescript
1966
- } else if (command === "mcp") {
1967
- if (!fs.existsSync(forgehiveDir)) {
1968
- console.error("Fehler: .forgehive/ nicht gefunden — führe zuerst `fh init` aus");
1969
- process.exit(1);
1970
- }
1971
-
1972
- if (subcommand === "auth") {
1973
- const authSub = rest[0];
1974
-
1975
- if (!authSub || authSub === "list") {
1976
- const services = listCredentialServices();
1977
- if (services.length === 0) {
1978
- console.log("Keine Credentials gespeichert. Verwende: fh mcp auth add <service> KEY=value");
1979
- } else {
1980
- console.log("Gespeicherte Credentials:\n");
1981
- for (const s of services) {
1982
- const creds = getCredentials(s);
1983
- const keys = Object.keys(creds ?? {}).join(", ");
1984
- console.log(` ${s.padEnd(16)} ${keys}`);
1985
- }
1986
- }
1987
- } else if (authSub === "add") {
1988
- const service = rest[1];
1989
- if (!service) {
1990
- console.error("Fehler: fh mcp auth add <service> KEY=value [KEY2=value2 ...]");
1991
- process.exit(1);
1992
- }
1993
- const pairs = rest.slice(2);
1994
- if (pairs.length === 0) {
1995
- console.error("Fehler: mindestens ein KEY=value Paar erforderlich");
1996
- process.exit(1);
1997
- }
1998
- const creds: Record<string, string> = {};
1999
- for (const pair of pairs) {
2000
- const eqIdx = pair.indexOf("=");
2001
- if (eqIdx === -1) { console.error(`Ungültiges Format: ${pair} (erwartet KEY=value)`); process.exit(1); }
2002
- creds[pair.slice(0, eqIdx)] = pair.slice(eqIdx + 1);
2003
- }
2004
- setCredentials(service, creds);
2005
- console.log(`✓ Credentials für ${service} gespeichert (${Object.keys(creds).join(", ")})`);
2006
-
2007
- } else if (authSub === "remove") {
2008
- const service = rest[1];
2009
- if (!service) { console.error("Fehler: fh mcp auth remove <service>"); process.exit(1); }
2010
- removeCredentials(service);
2011
- console.log(`✓ Credentials für ${service} entfernt`);
2012
-
2013
- } else {
2014
- console.error(`Unbekannter auth-Subcommand: ${authSub}`);
2015
- console.error("Verfügbar: list | add <service> KEY=value | remove <service>");
2016
- process.exit(1);
2017
- }
2018
-
2019
- } else {
2020
- console.error(`Unbekannter mcp-Subcommand: ${subcommand ?? "(kein)"}`);
2021
- console.error("Verfügbar: auth [list|add|remove]");
2022
- process.exit(1);
2023
- }
2024
-
2025
- ```
2026
-
2027
- Update the help message to include `mcp auth [list|add|remove]`.
2028
-
2029
- - [ ] **Step 5: Run all tests**
2030
-
2031
- ```bash
2032
- node --import tsx/esm --test test/*.test.ts 2>&1 | tail -8
2033
- ```
2034
-
2035
- Expected: `# pass 179` and `# fail 0`
2036
-
2037
- - [ ] **Step 6: Commit**
2038
-
2039
- ```bash
2040
- git add src/mcp-auth.ts test/mcp-auth.test.ts src/cli.ts
2041
- git commit -m "feat: MCP credential management — fh mcp auth add/list/remove stores keys at ~/.forgehive/credentials.json"
2042
- ```
2043
-
2044
- ---
2045
-
2046
- ### Task 10: Smithery Registry Integration
2047
-
2048
- **Files:**
2049
- - Create: `src/mcp-registry.ts`
2050
- - Modify: `src/cli.ts` (add `fh mcp search` and `fh mcp add`)
2051
- - Test: `test/mcp-registry.test.ts`
2052
-
2053
- - [ ] **Step 1: Write failing tests**
2054
-
2055
- ```typescript
2056
- // test/mcp-registry.test.ts
2057
- import { describe, it } from "node:test";
2058
- import assert from "node:assert/strict";
2059
- import { parseRegistryResponse, addMcpFromRegistry } from "../src/mcp-registry.ts";
2060
- import fs from "node:fs";
2061
- import path from "node:path";
2062
- import os from "node:os";
2063
-
2064
- describe("parseRegistryResponse()", () => {
2065
- it("returns empty array for null/undefined input", () => {
2066
- assert.deepEqual(parseRegistryResponse(null), []);
2067
- assert.deepEqual(parseRegistryResponse(undefined), []);
2068
- });
2069
-
2070
- it("parses Smithery-style response", () => {
2071
- const raw = {
2072
- servers: [
2073
- { qualifiedName: "@org/github-mcp", displayName: "GitHub MCP", description: "GitHub integration" },
2074
- { qualifiedName: "@org/slack-mcp", displayName: "Slack MCP", description: "Slack integration" },
2075
- ]
2076
- };
2077
- const results = parseRegistryResponse(raw);
2078
- assert.equal(results.length, 2);
2079
- assert.equal(results[0].qualifiedName, "@org/github-mcp");
2080
- assert.equal(results[0].displayName, "GitHub MCP");
2081
- });
2082
-
2083
- it("handles missing fields gracefully", () => {
2084
- const raw = { servers: [{ qualifiedName: "@org/server" }] };
2085
- const results = parseRegistryResponse(raw);
2086
- assert.equal(results.length, 1);
2087
- assert.equal(results[0].displayName, "@org/server");
2088
- });
2089
- });
2090
-
2091
- describe("addMcpFromRegistry()", () => {
2092
- let tmpDir: string;
2093
- beforeEach(() => {
2094
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "forgehive-mcp-registry-"));
2095
- fs.mkdirSync(path.join(tmpDir, ".forgehive", "skills", "workflows"), { recursive: true });
2096
- });
2097
- afterEach(() => fs.rmSync(tmpDir, { recursive: true, force: true }));
2098
-
2099
- it("writes .mcp.json with the package config", () => {
2100
- addMcpFromRegistry(tmpDir, path.join(tmpDir, ".forgehive"), "@org/my-mcp-server", ["MY_API_KEY"]);
2101
- const mcp = JSON.parse(fs.readFileSync(path.join(tmpDir, ".mcp.json"), "utf8"));
2102
- assert.ok(mcp.mcpServers?.["my-mcp-server"]);
2103
- assert.equal(mcp.mcpServers["my-mcp-server"].command, "npx");
2104
- assert.ok(mcp.mcpServers["my-mcp-server"].args.includes("@org/my-mcp-server"));
2105
- });
2106
-
2107
- it("adds env vars to the MCP config", () => {
2108
- addMcpFromRegistry(tmpDir, path.join(tmpDir, ".forgehive"), "@org/my-mcp", ["MY_KEY", "MY_SECRET"]);
2109
- const mcp = JSON.parse(fs.readFileSync(path.join(tmpDir, ".mcp.json"), "utf8"));
2110
- const server = mcp.mcpServers["my-mcp"];
2111
- assert.equal(server.env.MY_KEY, "${MY_KEY}");
2112
- assert.equal(server.env.MY_SECRET, "${MY_SECRET}");
2113
- });
2114
-
2115
- it("merges with existing .mcp.json without overwriting other services", () => {
2116
- fs.writeFileSync(
2117
- path.join(tmpDir, ".mcp.json"),
2118
- JSON.stringify({ mcpServers: { linear: { command: "npx", args: ["-y", "linear-mcp"] } } }),
2119
- "utf8"
2120
- );
2121
- addMcpFromRegistry(tmpDir, path.join(tmpDir, ".forgehive"), "@org/new-server", []);
2122
- const mcp = JSON.parse(fs.readFileSync(path.join(tmpDir, ".mcp.json"), "utf8"));
2123
- assert.ok(mcp.mcpServers.linear);
2124
- assert.ok(mcp.mcpServers["new-server"]);
2125
- });
2126
- });
2127
- ```
2128
-
2129
- - [ ] **Step 2: Run test to confirm it fails**
2130
-
2131
- ```bash
2132
- node --import tsx/esm --test test/mcp-registry.test.ts 2>&1 | head -5
2133
- ```
2134
-
2135
- Expected: `Error: Cannot find module '../src/mcp-registry.ts'`
2136
-
2137
- - [ ] **Step 3: Implement `src/mcp-registry.ts`**
2138
-
2139
- ```typescript
2140
- import fs from "node:fs";
2141
- import path from "node:path";
2142
- import { spawnSync } from "node:child_process";
2143
-
2144
- export interface RegistryServer {
2145
- qualifiedName: string;
2146
- displayName: string;
2147
- description: string;
2148
- homepage?: string;
2149
- }
2150
-
2151
- interface SmitheryResponse {
2152
- servers?: Array<{
2153
- qualifiedName?: string;
2154
- displayName?: string;
2155
- description?: string;
2156
- homepage?: string;
2157
- }>;
2158
- }
2159
-
2160
- export function parseRegistryResponse(raw: unknown): RegistryServer[] {
2161
- if (!raw || typeof raw !== "object") return [];
2162
- const data = raw as SmitheryResponse;
2163
- if (!Array.isArray(data.servers)) return [];
2164
- return data.servers.map(s => ({
2165
- qualifiedName: s.qualifiedName ?? "",
2166
- displayName: s.displayName ?? s.qualifiedName ?? "",
2167
- description: s.description ?? "",
2168
- homepage: s.homepage,
2169
- })).filter(s => s.qualifiedName !== "");
2170
- }
2171
-
2172
- export function searchRegistry(query: string): RegistryServer[] {
2173
- const url = `https://registry.smithery.ai/servers?q=${encodeURIComponent(query)}&pageSize=10`;
2174
-
2175
- const result = spawnSync("curl", [
2176
- "-s",
2177
- "--max-time", "10",
2178
- "-H", "Accept: application/json",
2179
- url,
2180
- ], { encoding: "utf8", timeout: 15000 });
2181
-
2182
- if (result.error) {
2183
- throw new Error(`curl nicht verfügbar: ${result.error.message}`);
2184
- }
2185
- if (result.status !== 0) {
2186
- throw new Error(`Registry-Abfrage fehlgeschlagen (HTTP ${result.status ?? "?"})`);
2187
- }
2188
-
2189
- try {
2190
- return parseRegistryResponse(JSON.parse(result.stdout));
2191
- } catch {
2192
- throw new Error("Registry-Antwort konnte nicht geparst werden");
2193
- }
2194
- }
2195
-
2196
- export function addMcpFromRegistry(
2197
- projectRoot: string,
2198
- forgehiveDir: string,
2199
- packageName: string,
2200
- envKeys: string[]
2201
- ): void {
2202
- // Derive a short server ID from the package name
2203
- const serverId = packageName.replace(/^@[^/]+\//, "").replace(/(-mcp|-server|mcp-)$/, "") || packageName;
2204
-
2205
- const mcpPath = path.join(projectRoot, ".mcp.json");
2206
- let mcpConfig: { mcpServers: Record<string, unknown> } = { mcpServers: {} };
2207
- if (fs.existsSync(mcpPath)) {
2208
- try {
2209
- mcpConfig = JSON.parse(fs.readFileSync(mcpPath, "utf8")) as typeof mcpConfig;
2210
- if (!mcpConfig.mcpServers) mcpConfig.mcpServers = {};
2211
- } catch { mcpConfig = { mcpServers: {} }; }
2212
- }
2213
-
2214
- const envObj: Record<string, string> = {};
2215
- for (const key of envKeys) {
2216
- envObj[key] = `\${${key}}`;
2217
- }
2218
-
2219
- mcpConfig.mcpServers[serverId] = {
2220
- command: "npx",
2221
- args: ["-y", packageName],
2222
- ...(envKeys.length > 0 ? { env: envObj } : {}),
2223
- };
2224
-
2225
- fs.writeFileSync(mcpPath, JSON.stringify(mcpConfig, null, 2), "utf8");
2226
-
2227
- // Write a stub workflow skill
2228
- const skillDir = path.join(forgehiveDir, "skills", "workflows");
2229
- fs.mkdirSync(skillDir, { recursive: true });
2230
- const skillPath = path.join(skillDir, `${serverId}.md`);
2231
- if (!fs.existsSync(skillPath)) {
2232
- fs.writeFileSync(skillPath, [
2233
- `# ${serverId} Workflow Skill`,
2234
- "",
2235
- `## Setup`,
2236
- `MCP server: \`${packageName}\``,
2237
- envKeys.length > 0 ? `Required env: ${envKeys.map(k => `\`${k}\``).join(", ")}` : "",
2238
- "",
2239
- `## Capabilities`,
2240
- "",
2241
- `_Fill in after installing and exploring the server tools._`,
2242
- ].filter(l => l !== null).join("\n") + "\n", "utf8");
2243
- }
2244
- }
2245
- ```
2246
-
2247
- - [ ] **Step 4: Add `fh mcp search` and `fh mcp add` to `src/cli.ts`**
2248
-
2249
- Add import at top:
2250
- ```typescript
2251
- import { searchRegistry, addMcpFromRegistry } from "./mcp-registry.ts";
2252
- ```
2253
-
2254
- Inside the existing `mcp` command handler (after `auth`), add two more `else if` branches:
2255
-
2256
- ```typescript
2257
- } else if (subcommand === "search") {
2258
- const query = rest[0];
2259
- if (!query) {
2260
- console.error("Fehler: fh mcp search <suchbegriff>");
2261
- process.exit(1);
2262
- }
2263
- try {
2264
- console.log(`Suche in Smithery Registry nach "${query}"...\n`);
2265
- const results = searchRegistry(query);
2266
- if (results.length === 0) {
2267
- console.log("Keine Ergebnisse gefunden.");
2268
- } else {
2269
- for (const r of results) {
2270
- console.log(` ${r.qualifiedName}`);
2271
- console.log(` ${r.displayName} — ${r.description.slice(0, 80)}`);
2272
- console.log(` Installieren: fh mcp add ${r.qualifiedName}\n`);
2273
- }
2274
- }
2275
- } catch (err) {
2276
- console.error(`Fehler: ${(err as Error).message}`);
2277
- process.exit(1);
2278
- }
2279
-
2280
- } else if (subcommand === "add") {
2281
- const packageName = rest[0];
2282
- if (!packageName) {
2283
- console.error("Fehler: fh mcp add <npm-paketname> [--env KEY1 KEY2 ...]");
2284
- process.exit(1);
2285
- }
2286
- const envIdx = rest.indexOf("--env");
2287
- const envKeys = envIdx !== -1 ? rest.slice(envIdx + 1) : [];
2288
- try {
2289
- addMcpFromRegistry(projectRoot, forgehiveDir, packageName, envKeys);
2290
- console.log(`✓ ${packageName} zu .mcp.json hinzugefügt`);
2291
- if (envKeys.length > 0) {
2292
- console.log(`\nSetze diese Umgebungsvariablen:`);
2293
- for (const k of envKeys) console.log(` export ${k}="<your-key>"`);
2294
- }
2295
- console.log("\nStarte Claude Code neu um den MCP-Server zu aktivieren.");
2296
- } catch (err) {
2297
- console.error(`Fehler: ${(err as Error).message}`);
2298
- process.exit(1);
2299
- }
2300
- ```
2301
-
2302
- Update help message: `mcp [auth|search|add]`.
2303
-
2304
- - [ ] **Step 5: Build and run all tests**
2305
-
2306
- ```bash
2307
- node --import tsx/esm --test test/*.test.ts 2>&1 | tail -8
2308
- ```
2309
-
2310
- Expected: `# pass 187` and `# fail 0`
2311
-
2312
- ```bash
2313
- node_modules/.bin/esbuild src/cli.ts --bundle --platform=node --format=esm --outfile=dist/cli.js && chmod +x dist/cli.js
2314
- ```
2315
-
2316
- Expected: `dist/cli.js 150-180 kb, 0 errors`
2317
-
2318
- - [ ] **Step 6: Commit**
2319
-
2320
- ```bash
2321
- git add src/mcp-registry.ts test/mcp-registry.test.ts src/cli.ts
2322
- git commit -m "feat: Smithery registry integration — fh mcp search <query> + fh mcp add <package>"
2323
- ```
2324
-
2325
- ---
2326
-
2327
- ## Final: Bump version + integration check
2328
-
2329
- - [ ] **Step 1: Update version in `package.json`**
2330
-
2331
- ```json
2332
- "version": "0.5.0"
2333
- ```
2334
-
2335
- - [ ] **Step 2: Run full test suite**
2336
-
2337
- ```bash
2338
- node --import tsx/esm --test test/*.test.ts 2>&1 | tail -8
2339
- ```
2340
-
2341
- Expected: `# fail 0`
2342
-
2343
- - [ ] **Step 3: TypeScript typecheck**
2344
-
2345
- ```bash
2346
- node_modules/.bin/tsc --noEmit 2>&1
2347
- ```
2348
-
2349
- Expected: zero errors
2350
-
2351
- - [ ] **Step 4: Final build**
2352
-
2353
- ```bash
2354
- node_modules/.bin/esbuild src/cli.ts --bundle --platform=node --format=esm --outfile=dist/cli.js && chmod +x dist/cli.js && ls -lh dist/cli.js
2355
- ```
2356
-
2357
- Expected: single output file, no errors
2358
-
2359
- - [ ] **Step 5: Commit**
2360
-
2361
- ```bash
2362
- git add package.json dist/cli.js
2363
- git commit -m "chore: bump to v0.5.0 — NextGen v0.5 complete"
2364
- ```