forgehive 0.7.7 → 0.7.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- package/dist/cli.js +9 -31
- package/docs/user-guide.md +8 -3
- package/forgehive/commands/fh-refactor.md +116 -0
- package/forgehive/commands/review-party.md +42 -23
- package/forgehive/party/defaults.yaml +8 -0
- package/package.json +2 -2
- package/docs/superpowers/plans/2026-05-11-nextgen-v04-v05.md +0 -1708
- package/docs/superpowers/plans/2026-05-11-v05-nextgen-gaps.md +0 -2364
- package/docs/superpowers/plans/2026-05-14-sprint-planner-v2.md +0 -1058
- package/docs/superpowers/plans/2026-05-14-v07-nextgen.md +0 -1980
|
@@ -1,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
|
-
```
|