@warpgogol/forge 3.0.0 → 4.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/AGENTS.md +18 -3
  2. package/bin/cli.ts +15 -8
  3. package/os/adr/adr.module.ts +24 -23
  4. package/os/adr/index.ts +1 -1
  5. package/os/audit/audit.module.ts +13 -8
  6. package/os/audit/index.ts +1 -1
  7. package/os/compass/compass.module.ts +22 -19
  8. package/os/core/core.module.ts +842 -864
  9. package/os/core/handlers/package-health.ts +21 -0
  10. package/os/core/index.ts +1 -1
  11. package/os/exploration/exploration.module.ts +19 -16
  12. package/os/exploration/index.ts +2 -2
  13. package/os/mission/index.ts +1 -1
  14. package/os/mission/mission.module.ts +13 -8
  15. package/os/naming/index.ts +1 -1
  16. package/os/naming/naming-convention.ts +1 -0
  17. package/os/naming/naming.module.ts +13 -7
  18. package/os/notes/index.ts +2 -2
  19. package/os/notes/notes.module.ts +20 -17
  20. package/os/plan/index.ts +1 -1
  21. package/os/plan/plan.module.ts +13 -8
  22. package/os/plugin/plugin.module.ts +10 -8
  23. package/os/program/program.module.ts +22 -20
  24. package/os/rfc/handlers/implement-stamp.ts +17 -1
  25. package/os/rfc/index.ts +1 -1
  26. package/os/rfc/rfc-0000-template.md +3 -2
  27. package/os/rfc/rfc.module.ts +54 -84
  28. package/os/rfc/types.ts +1 -0
  29. package/os/session/handlers/metrics-aggregate.ts +208 -0
  30. package/os/session/handlers/metrics-rfc.ts +339 -0
  31. package/os/session/handlers/metrics-session.ts +209 -0
  32. package/os/session/handlers/save.ts +19 -0
  33. package/os/session/index.ts +16 -1
  34. package/os/session/session.module.ts +133 -107
  35. package/os/session/types.ts +99 -0
  36. package/os/spec/spec.module.ts +28 -29
  37. package/os/werkstatt/werkstatt.module.ts +12 -9
  38. package/os/workflow/index.ts +1 -1
  39. package/os/workflow/workflow.module.ts +18 -12
  40. package/package.json +3 -1
  41. package/src/forge-module.ts +24 -10
  42. package/src/index.ts +12 -12
  43. package/src/onboarding/scaffold.ts +6 -4
  44. package/src/tests/metrics-rfc-1053.test.ts +355 -0
  45. package/src/types/werkstatt-engine-shims.d.ts +126 -48
  46. package/src/types/werkstatt-shared-shims.d.ts +6 -0
@@ -15,11 +15,28 @@
15
15
 
16
16
  import type { ForgeCommandDefinition } from "./types.ts";
17
17
 
18
- // ForgeModule is structurally compatible with KernelModule from @warpgogol/site-kernel.
19
- // Forge does NOT import from site-kernel — TypeScript structural typing ensures
20
- // compatibility. If the kernel's KernelModule interface changes, the build fails
21
- // at the point where forge modules are imported into kernel.config.ts.
18
+ // ForgeModule is structurally compatible with ModuleExport from
19
+ // @warpgogol/werkstatt-engine/runtime/desired-state. Forge does NOT import from
20
+ // werkstatt-engine TypeScript structural typing ensures compatibility.
21
+ // If the ModuleExport interface changes, the build fails at the point where
22
+ // forge modules are imported into kernel.config.ts.
22
23
 
24
+ export interface ForgePipelineStep {
25
+ command: string;
26
+ args?: string[];
27
+ }
28
+
29
+ export interface ForgePipelineDeclaration {
30
+ name: string;
31
+ steps: ForgePipelineStep[];
32
+ }
33
+
34
+ /**
35
+ * RFC-1038: ForgeModuleRegistry is kept for the CLI's standalone registry
36
+ * implementation. The kernel no longer uses it — modules export commands/
37
+ * pipelines arrays directly. The CLI uses it to collect commands from
38
+ * ForgeModule.commands[] into its own registry.
39
+ */
23
40
  export interface ForgeModuleRegistry {
24
41
  registerCommand(command: ForgeCommandDefinition): void;
25
42
  registerPipeline(name: string, steps: ForgePipelineStep[]): void;
@@ -29,13 +46,10 @@ export interface ForgeModule {
29
46
  name: string;
30
47
  version: string;
31
48
  runtime: "autonomous" | "werkstatt-adapter";
32
- register(registry: ForgeModuleRegistry): void | Promise<void>;
49
+ declarations: never[];
50
+ commands: ForgeCommandDefinition[];
51
+ pipelines: ForgePipelineDeclaration[];
33
52
  }
34
53
 
35
54
  // Re-export canonical types for convenience
36
55
  export type { ForgeCommandDefinition, ForgeCommandResult, ForgeFlagSpec } from "./types.ts";
37
-
38
- export interface ForgePipelineStep {
39
- command: string;
40
- args?: string[];
41
- }
package/src/index.ts CHANGED
@@ -173,18 +173,18 @@ export type {
173
173
  } from "./compass/types.ts";
174
174
 
175
175
  // OS modules
176
- export { forgeCoreModule } from "../os/core/core.module.ts";
177
- export { forgeRfcModule } from "../os/rfc/rfc.module.ts";
178
- export { forgeWorkflowModule } from "../os/workflow/workflow.module.ts";
179
- export { forgeNamingModule } from "../os/naming/naming.module.ts";
176
+ export { createForgeCoreModule } from "../os/core/core.module.ts";
177
+ export { createForgeRfcModule } from "../os/rfc/rfc.module.ts";
178
+ export { createForgeWorkflowModule } from "../os/workflow/workflow.module.ts";
179
+ export { createForgeNamingModule } from "../os/naming/naming.module.ts";
180
180
  export { forgeCompassModule } from "../os/compass/compass.module.ts";
181
181
  export { forgeWerkstattModule } from "../os/werkstatt/werkstatt.module.ts";
182
- export { forgeSpecModule } from "../os/spec/spec.module.ts";
183
- export { forgeAdrModule } from "../os/adr/adr.module.ts";
184
- export { forgePlanModule } from "../os/plan/plan.module.ts";
185
- export { forgeAuditModule } from "../os/audit/audit.module.ts";
186
- export { forgeMissionModule } from "../os/mission/mission.module.ts";
187
- export { forgeExplorationModule } from "../os/exploration/exploration.module.ts";
188
- export { forgeNotesModule } from "../os/notes/notes.module.ts";
189
- export { forgeProgramModule } from "../os/program/program.module.ts";
182
+ export { createForgeSpecModule } from "../os/spec/spec.module.ts";
183
+ export { createForgeAdrModule } from "../os/adr/adr.module.ts";
184
+ export { createForgePlanModule } from "../os/plan/plan.module.ts";
185
+ export { createForgeAuditModule } from "../os/audit/audit.module.ts";
186
+ export { createForgeMissionModule } from "../os/mission/mission.module.ts";
187
+ export { createForgeExplorationModule } from "../os/exploration/exploration.module.ts";
188
+ export { createForgeNotesModule } from "../os/notes/notes.module.ts";
189
+ export { createForgeProgramModule } from "../os/program/program.module.ts";
190
190
  export { forgePluginModule } from "../os/plugin/plugin.module.ts";
@@ -112,16 +112,18 @@ export const forge${capitalize(name)}Module: ForgeModule = {
112
112
  name: "forge-${name}",
113
113
  version: "0.1.0",
114
114
  runtime: "autonomous",
115
- register(registry) {
116
- registry.registerCommand({
115
+ declarations: [],
116
+ commands: [
117
+ {
117
118
  name: "${name}",
118
119
  description: "TODO — one-line description",
119
120
  scope: "workspace",
120
121
  execute() {
121
122
  // TODO: implement
122
123
  },
123
- } satisfies ForgeCommandDefinition);
124
- },
124
+ } satisfies ForgeCommandDefinition,
125
+ ],
126
+ pipelines: [],
125
127
  };
126
128
  `;
127
129
 
@@ -0,0 +1,355 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { mkdtemp, rm, mkdir, writeFile, readFile, readdir } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { execFileSync } from "node:child_process";
6
+
7
+ import { generateRfcMetrics } from "../../os/session/handlers/metrics-rfc.ts";
8
+ import { generateSessionMetrics } from "../../os/session/handlers/metrics-session.ts";
9
+ import { runMetricsAggregate } from "../../os/session/handlers/metrics-aggregate.ts";
10
+ import type { AtifMessage } from "../../os/session/atif-parser.ts";
11
+ import type { ForgeCommandInput, ForgeRuntimeContext, ForgeLogger } from "../../src/types.ts";
12
+
13
+ // ─── Test helpers ─────────────────────────────────────────────────────────────
14
+
15
+ const noopLogger: ForgeLogger = {
16
+ section: () => {},
17
+ info: () => {},
18
+ warn: () => {},
19
+ error: () => {},
20
+ success: () => {},
21
+ };
22
+
23
+ function makeContext(workspaceRoot: string): ForgeRuntimeContext {
24
+ return {
25
+ workspaceRoot,
26
+ logger: noopLogger,
27
+ dryRun: false,
28
+ outputFormat: "json",
29
+ };
30
+ }
31
+
32
+ function gitInit(workspaceRoot: string): void {
33
+ execFileSync("git", ["init", "--quiet"], { cwd: workspaceRoot });
34
+ execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: workspaceRoot });
35
+ execFileSync("git", ["config", "user.name", "Test"], { cwd: workspaceRoot });
36
+ }
37
+
38
+ function gitCommit(workspaceRoot: string, message: string): string {
39
+ execFileSync("git", ["add", "-A"], { cwd: workspaceRoot });
40
+ execFileSync("git", ["commit", "--quiet", "-m", message, "--allow-empty"], { cwd: workspaceRoot });
41
+ const sha = execFileSync("git", ["rev-parse", "HEAD"], { cwd: workspaceRoot }).toString().trim();
42
+ return sha;
43
+ }
44
+
45
+ // ─── Tests ────────────────────────────────────────────────────────────────────
46
+
47
+ describe("RFC-1053: generateRfcMetrics", () => {
48
+ let tmpDir: string;
49
+
50
+ beforeEach(async () => {
51
+ tmpDir = await mkdtemp(join(tmpdir(), "metrics-rfc-XXXXXX-"));
52
+ gitInit(tmpDir);
53
+ });
54
+
55
+ afterEach(async () => {
56
+ await rm(tmpDir, { recursive: true, force: true });
57
+ });
58
+
59
+ it("writes metrics YAML to docs/metrics/rfcs/<rfc-id>.metrics.yaml", async () => {
60
+ const rfcId = "RFC-9999";
61
+ const rfcDir = join(tmpDir, "docs", "rfcs");
62
+ await mkdir(rfcDir, { recursive: true });
63
+ await writeFile(
64
+ join(rfcDir, "rfc-9999-test.md"),
65
+ `---
66
+ id: ${rfcId}
67
+ title: "Test RFC"
68
+ status: implemented
69
+ kind: architecture
70
+ scope: workspace
71
+ owners:
72
+ - architecture
73
+ ---
74
+
75
+ # ${rfcId}: Test RFC
76
+
77
+ ## Acceptance criteria
78
+
79
+ - [x] Criterion 1 (evidence: test:foo)
80
+ - [x] Criterion 2 (evidence: test:bar)
81
+ - [ ] Criterion 3
82
+ `,
83
+ );
84
+
85
+ gitCommit(tmpDir, `audit: ${rfcId} — test audit`);
86
+ gitCommit(tmpDir, `enhance: ${rfcId} — test enhance`);
87
+ gitCommit(tmpDir, `plan: ${rfcId} — test plan`);
88
+ gitCommit(tmpDir, `implement: ${rfcId} — test implement`);
89
+
90
+ const metricsPath = await generateRfcMetrics(tmpDir, rfcId, noopLogger);
91
+
92
+ expect(metricsPath).toBe("docs/metrics/rfcs/rfc-9999.metrics.yaml");
93
+ const fileContent = await readFile(join(tmpDir, metricsPath), "utf-8");
94
+ expect(fileContent).toContain("rfcId: RFC-9999");
95
+ expect(fileContent).toContain("pipeline:");
96
+ expect(fileContent).toContain("step: audit");
97
+ expect(fileContent).toContain("step: enhance");
98
+ expect(fileContent).toContain("step: plan");
99
+ expect(fileContent).toContain("step: implement");
100
+ expect(fileContent).toContain("skill: fo-idea-audit");
101
+ expect(fileContent).toContain("skill: fo-idea-implement");
102
+ });
103
+
104
+ it("classifies commit prefixes into pipeline steps with correct skills", async () => {
105
+ const rfcId = "RFC-8888";
106
+ const rfcDir = join(tmpDir, "docs", "rfcs");
107
+ await mkdir(rfcDir, { recursive: true });
108
+ await writeFile(
109
+ join(rfcDir, "rfc-8888-test.md"),
110
+ `---
111
+ id: ${rfcId}
112
+ title: "Test"
113
+ status: implemented
114
+ kind: architecture
115
+ scope: workspace
116
+ owners:
117
+ - architecture
118
+ ---
119
+
120
+ # ${rfcId}
121
+
122
+ ## Acceptance criteria
123
+
124
+ - [x] AC1
125
+ `,
126
+ );
127
+
128
+ gitCommit(tmpDir, `review: ${rfcId} — code review`);
129
+ gitCommit(tmpDir, `fix: ${rfcId} — fix issue`);
130
+
131
+ const metricsPath = await generateRfcMetrics(tmpDir, rfcId, noopLogger);
132
+ const fileContent = await readFile(join(tmpDir, metricsPath), "utf-8");
133
+
134
+ expect(fileContent).toContain("step: review");
135
+ expect(fileContent).toContain("skill: fo-review");
136
+ expect(fileContent).toContain("step: fix");
137
+ expect(fileContent).toContain("skill: fo-fix");
138
+ });
139
+
140
+ it("handles RFC with no commits gracefully", async () => {
141
+ const rfcId = "RFC-7777";
142
+ const metricsPath = await generateRfcMetrics(tmpDir, rfcId, noopLogger);
143
+
144
+ expect(metricsPath).toBe("docs/metrics/rfcs/rfc-7777.metrics.yaml");
145
+ const fileContent = await readFile(join(tmpDir, metricsPath), "utf-8");
146
+ expect(fileContent).toContain("rfcId: RFC-7777");
147
+ expect(fileContent).toContain("pipeline: []");
148
+ });
149
+ });
150
+
151
+ describe("RFC-1053: generateSessionMetrics", () => {
152
+ let tmpDir: string;
153
+
154
+ beforeEach(async () => {
155
+ tmpDir = await mkdtemp(join(tmpdir(), "metrics-session-XXXXXX-"));
156
+ });
157
+
158
+ afterEach(async () => {
159
+ await rm(tmpDir, { recursive: true, force: true });
160
+ });
161
+
162
+ it("extracts skill invocations from ATIF messages", async () => {
163
+ const messages: AtifMessage[] = [
164
+ { role: "user", timestamp: "2026-09-06T10:00:00Z", content: "Please run fo-idea-audit on RFC-1053" },
165
+ { role: "assistant", timestamp: "2026-09-06T10:05:00Z", content: "Running fo-idea-audit now" },
166
+ { role: "user", timestamp: "2026-09-06T10:10:00Z", content: "Now run fo-review" },
167
+ { role: "assistant", timestamp: "2026-09-06T10:15:00Z", content: "Running fo-review" },
168
+ ];
169
+
170
+ const metricsPath = await generateSessionMetrics(
171
+ tmpDir,
172
+ "2026-09-06-10-00-00-abc123",
173
+ messages,
174
+ { date: "2026-09-06", relatedRfcs: ["RFC-1053"], commits: ["abc1234"] },
175
+ noopLogger,
176
+ );
177
+
178
+ expect(metricsPath).toBe("docs/metrics/sessions/2026-09-06-10-00-00-abc123.metrics.yaml");
179
+ const fileContent = await readFile(join(tmpDir, metricsPath), "utf-8");
180
+ expect(fileContent).toContain("sessionId: 2026-09-06-10-00-00-abc123");
181
+ expect(fileContent).toContain("approximateDurations: true");
182
+ expect(fileContent).toContain("fo-idea-audit");
183
+ expect(fileContent).toContain("fo-review");
184
+ });
185
+
186
+ it("computes approximate duration from message timestamps", async () => {
187
+ const messages: AtifMessage[] = [
188
+ { role: "user", timestamp: "2026-09-06T10:00:00Z", content: "Run fo-review" },
189
+ { role: "assistant", timestamp: "2026-09-06T10:30:00Z", content: "fo-review done" },
190
+ ];
191
+
192
+ const metricsPath = await generateSessionMetrics(
193
+ tmpDir,
194
+ "test-session-001",
195
+ messages,
196
+ { date: "2026-09-06" },
197
+ noopLogger,
198
+ );
199
+
200
+ const fileContent = await readFile(join(tmpDir, metricsPath), "utf-8");
201
+ expect(fileContent).toContain("approximateDurationMs:");
202
+ // 30 minutes = 1,800,000 ms
203
+ expect(fileContent).toContain("1800000");
204
+ });
205
+
206
+ it("handles empty messages gracefully", async () => {
207
+ const metricsPath = await generateSessionMetrics(
208
+ tmpDir,
209
+ "empty-session",
210
+ [],
211
+ { date: "2026-09-06" },
212
+ noopLogger,
213
+ );
214
+
215
+ const fileContent = await readFile(join(tmpDir, metricsPath), "utf-8");
216
+ expect(fileContent).toContain("sessionId: empty-session");
217
+ expect(fileContent).toContain("skills: []");
218
+ });
219
+ });
220
+
221
+ describe("RFC-1053: metrics.aggregate", () => {
222
+ let tmpDir: string;
223
+
224
+ beforeEach(async () => {
225
+ tmpDir = await mkdtemp(join(tmpdir(), "metrics-agg-XXXXXX-"));
226
+ });
227
+
228
+ afterEach(async () => {
229
+ await rm(tmpDir, { recursive: true, force: true });
230
+ });
231
+
232
+ it("returns empty result when no metrics files exist", async () => {
233
+ const input: ForgeCommandInput = { argv: [], flags: {} };
234
+ const result = await runMetricsAggregate(input, makeContext(tmpDir));
235
+
236
+ expect(result.data?.status).toBe("ok");
237
+ expect(result.data?.perSkill).toEqual([]);
238
+ expect(result.data?.totalRfcs).toBe(0);
239
+ expect(result.data?.totalSessions).toBe(0);
240
+ });
241
+
242
+ it("aggregates skill data from RFC and session metrics files", async () => {
243
+ // Create RFC metrics
244
+ const rfcMetricsDir = join(tmpDir, "docs", "metrics", "rfcs");
245
+ await mkdir(rfcMetricsDir, { recursive: true });
246
+ await writeFile(
247
+ join(rfcMetricsDir, "rfc-9999.metrics.yaml"),
248
+ `rfcId: RFC-9999
249
+ generatedAt: 2026-09-06T10:00:00Z
250
+ pipeline:
251
+ - step: audit
252
+ skill: fo-idea-audit
253
+ commitSha: abc123
254
+ timestamp: "2026-09-06T09:00:00Z"
255
+ - step: implement
256
+ skill: fo-idea-implement
257
+ commitSha: def456
258
+ timestamp: "2026-09-06T10:00:00Z"
259
+ review:
260
+ findingsCount: 3
261
+ findingsByAxis:
262
+ Structural correctness: 2
263
+ DNA alignment: 1
264
+ verdict: approved
265
+ fix:
266
+ fixesApplied: 2
267
+ commitSha: ghi789
268
+ iterations: 2
269
+ verification: null
270
+ result:
271
+ acceptanceCriteriaTotal: 5
272
+ acceptanceCriteriaMet: 5
273
+ timings:
274
+ firstCommitAt: "2026-09-06T09:00:00Z"
275
+ lastCommitAt: "2026-09-06T10:00:00Z"
276
+ totalDurationMs: 3600000
277
+ `,
278
+ );
279
+
280
+ // Create session metrics
281
+ const sessionMetricsDir = join(tmpDir, "docs", "metrics", "sessions");
282
+ await mkdir(sessionMetricsDir, { recursive: true });
283
+ await writeFile(
284
+ join(sessionMetricsDir, "test-session.metrics.yaml"),
285
+ `sessionId: test-session
286
+ generatedAt: 2026-09-06T11:00:00Z
287
+ date: "2026-09-06"
288
+ durationMs: 1800000
289
+ approximateDurations: true
290
+ documents:
291
+ - rfcId: RFC-9999
292
+ status: implemented
293
+ metricsFile: docs/metrics/rfcs/rfc-9999.metrics.yaml
294
+ skills:
295
+ - skill: fo-idea-audit
296
+ approximateDurationMs: 300000
297
+ - skill: fo-review
298
+ approximateDurationMs: 600000
299
+ insights: null
300
+ commits:
301
+ - abc123
302
+ `,
303
+ );
304
+
305
+ const input: ForgeCommandInput = { argv: [], flags: {} };
306
+ const result = await runMetricsAggregate(input, makeContext(tmpDir));
307
+
308
+ expect(result.data?.totalRfcs).toBe(1);
309
+ expect(result.data?.totalSessions).toBe(1);
310
+
311
+ const auditSkill = result.data?.perSkill.find((s) => s.skill === "fo-idea-audit");
312
+ expect(auditSkill).toBeDefined();
313
+ expect(auditSkill?.invocations).toBeGreaterThanOrEqual(1);
314
+
315
+ const reviewSkill = result.data?.perSkill.find((s) => s.skill === "fo-review");
316
+ expect(reviewSkill).toBeDefined();
317
+ expect(reviewSkill?.avgFindings).toBe(3);
318
+ });
319
+
320
+ it("filters by skill name when --skill is provided", async () => {
321
+ const rfcMetricsDir = join(tmpDir, "docs", "metrics", "rfcs");
322
+ await mkdir(rfcMetricsDir, { recursive: true });
323
+ await writeFile(
324
+ join(rfcMetricsDir, "rfc-9999.metrics.yaml"),
325
+ `rfcId: RFC-9999
326
+ generatedAt: 2026-09-06T10:00:00Z
327
+ pipeline:
328
+ - step: audit
329
+ skill: fo-idea-audit
330
+ commitSha: abc123
331
+ timestamp: "2026-09-06T09:00:00Z"
332
+ - step: review
333
+ skill: fo-review
334
+ commitSha: def456
335
+ timestamp: "2026-09-06T10:00:00Z"
336
+ review: null
337
+ fix: null
338
+ verification: null
339
+ result:
340
+ acceptanceCriteriaTotal: 0
341
+ acceptanceCriteriaMet: 0
342
+ timings:
343
+ firstCommitAt: null
344
+ lastCommitAt: null
345
+ totalDurationMs: null
346
+ `,
347
+ );
348
+
349
+ const input: ForgeCommandInput = { argv: [], flags: { skill: "fo-review" } };
350
+ const result = await runMetricsAggregate(input, makeContext(tmpDir));
351
+
352
+ expect(result.data?.perSkill.length).toBe(1);
353
+ expect(result.data?.perSkill[0]?.skill).toBe("fo-review");
354
+ });
355
+ });