@warpgogol/forge 4.0.0 → 4.1.2

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 +20 -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
@@ -0,0 +1,339 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>
4
+ RFC-1053: Generate per-RFC skill effectiveness metrics as structured YAML.
5
+ Reconstructs the RFC pipeline (audit, enhance, plan, implement, review, fix)
6
+ from git commit history, review reports, and verification evidence.
7
+ Writes to docs/metrics/rfcs/<rfc-id>.metrics.yaml via writeFileAtomic.
8
+ </purpose>
9
+ <non-goals>
10
+ <item>Does not execute kernel commands — reads existing artifacts only.</item>
11
+ <item>Does not extract quantitative results from acceptance criteria.</item>
12
+ <item>Does not block the stamp on metrics failure — caller wraps in try/catch.</item>
13
+ </non-goals>
14
+ </MODULE_CONTRACT>
15
+ <CHANGE_SUMMARY>
16
+ <item>RFC-1053: initial generateRfcMetrics pure function.</item>
17
+ </CHANGE_SUMMARY>
18
+ */
19
+
20
+ import { execFile } from "node:child_process";
21
+ import { readFile, readdir, mkdir } from "node:fs/promises";
22
+ import { join, dirname } from "node:path";
23
+
24
+ import { writeFileAtomic } from "../../../src/utils/fs-atomic.ts";
25
+ import { parse as yamlParse, stringify as yamlStringify } from "yaml";
26
+
27
+ import {
28
+ METRICS_DIR,
29
+ type RfcMetrics,
30
+ type RfcPipelineStep,
31
+ type ReviewMetrics,
32
+ type FixMetrics,
33
+ type VerificationMetrics,
34
+ type ResultMetrics,
35
+ type RfcTimings,
36
+ } from "../types.ts";
37
+
38
+ // ─── Git helper ──────────────────────────────────────────────────────────────
39
+
40
+ function execGit(workspaceRoot: string, args: string[]): Promise<string> {
41
+ return new Promise((resolve) => {
42
+ execFile("git", args, { cwd: workspaceRoot, timeout: 10000 }, (err, stdout) => {
43
+ if (err) {
44
+ resolve("");
45
+ return;
46
+ }
47
+ resolve(stdout.trim());
48
+ });
49
+ });
50
+ }
51
+
52
+ // ─── Pipeline step classification ────────────────────────────────────────────
53
+
54
+ const STEP_PREFIXES: Record<string, { step: string; skill: string | null }> = {
55
+ "audit:": { step: "audit", skill: "fo-idea-audit" },
56
+ "enhance:": { step: "enhance", skill: "fo-idea-enhance" },
57
+ "plan:": { step: "plan", skill: "fo-idea-plan" },
58
+ "implement:": { step: "implement", skill: "fo-idea-implement" },
59
+ "review:": { step: "review", skill: "fo-review" },
60
+ "fix:": { step: "fix", skill: "fo-fix" },
61
+ "evidence:": { step: "evidence", skill: null },
62
+ "test:": { step: "test", skill: null },
63
+ "docs:": { step: "docs", skill: null },
64
+ "trace:": { step: "trace", skill: null },
65
+ };
66
+
67
+ interface GitLogEntry {
68
+ sha: string;
69
+ message: string;
70
+ timestamp: string;
71
+ }
72
+
73
+ async function getRfcCommits(workspaceRoot: string, rfcId: string): Promise<GitLogEntry[]> {
74
+ const format = "%H%x1f%s%x1f%cI";
75
+ const raw = await execGit(workspaceRoot, [
76
+ "log",
77
+ "--no-merges",
78
+ `--grep=${rfcId}`,
79
+ `--format=${format}`,
80
+ ]);
81
+ if (!raw) return [];
82
+
83
+ const entries: GitLogEntry[] = [];
84
+ const lines = raw.split("\n");
85
+ for (const line of lines) {
86
+ const parts = line.split("\x1f");
87
+ if (parts.length >= 3) {
88
+ entries.push({
89
+ sha: parts[0]!.trim(),
90
+ message: parts[1]!.trim(),
91
+ timestamp: parts[2]!.trim(),
92
+ });
93
+ }
94
+ }
95
+ return entries;
96
+ }
97
+
98
+ function classifyCommit(message: string): { step: string; skill: string | null } | null {
99
+ const firstLine = message.split("\n")[0] ?? "";
100
+ for (const [prefix, mapping] of Object.entries(STEP_PREFIXES)) {
101
+ if (firstLine.startsWith(prefix)) {
102
+ return mapping;
103
+ }
104
+ }
105
+ return null;
106
+ }
107
+
108
+ // ─── Review report parsing ───────────────────────────────────────────────────
109
+
110
+ async function findReviewReport(workspaceRoot: string, rfcId: string): Promise<string | null> {
111
+ const reviewDir = join(workspaceRoot, "docs", "reviews", "code");
112
+ let files: string[];
113
+ try {
114
+ files = await readdir(reviewDir, { recursive: true });
115
+ } catch {
116
+ return null;
117
+ }
118
+
119
+ const rfcLower = rfcId.toLowerCase();
120
+ for (const file of files) {
121
+ if (!file.endsWith(".md")) continue;
122
+ if (file.toLowerCase().includes(rfcLower)) {
123
+ try {
124
+ const content = await readFile(join(reviewDir, file), "utf-8");
125
+ return content;
126
+ } catch {
127
+ continue;
128
+ }
129
+ }
130
+ }
131
+ return null;
132
+ }
133
+
134
+ function parseReviewReport(content: string): ReviewMetrics {
135
+ const findingsByAxis: Record<string, number> = {};
136
+ let findingsCount = 0;
137
+ let verdict: string | null = null;
138
+
139
+ const verdictMatch = content.match(/##\s*Verdict[:\s]+(.+)/i);
140
+ if (verdictMatch) {
141
+ verdict = verdictMatch[1]!.trim().toLowerCase();
142
+ }
143
+
144
+ const axisRegex = /##\s*Axis\s+[A-Z]\s*—\s*(.+)/gi;
145
+ let match;
146
+ while ((match = axisRegex.exec(content)) !== null) {
147
+ const axisName = match[1]!.trim();
148
+ const sectionStart = match.index! + match[0].length;
149
+ const nextAxisMatch = content.slice(sectionStart).match(/##\s*Axis\s+[A-Z]\s*—/i);
150
+ const sectionEnd = nextAxisMatch ? sectionStart + nextAxisMatch.index! : content.length;
151
+ const section = content.slice(sectionStart, sectionEnd);
152
+
153
+ const failMatches = section.match(/\*\*FAIL\*\*/g);
154
+ const failCount = failMatches ? failMatches.length : 0;
155
+ if (failCount > 0) {
156
+ findingsByAxis[axisName] = failCount;
157
+ findingsCount += failCount;
158
+ }
159
+ }
160
+
161
+ return { findingsCount, findingsByAxis, verdict };
162
+ }
163
+
164
+ // ─── Verification evidence parsing ───────────────────────────────────────────
165
+
166
+ async function parseVerificationEvidence(
167
+ workspaceRoot: string,
168
+ rfcId: string,
169
+ ): Promise<VerificationMetrics | null> {
170
+ const rfcNum = rfcId.replace(/^RFC-/, "").toLowerCase();
171
+ const evidencePath = join(
172
+ workspaceRoot,
173
+ "docs",
174
+ "rfcs",
175
+ "verification",
176
+ `rfc-${rfcNum}.generated.yaml`,
177
+ );
178
+ let content: string;
179
+ try {
180
+ content = await readFile(evidencePath, "utf-8");
181
+ } catch {
182
+ return null;
183
+ }
184
+
185
+ try {
186
+ const data = yamlParse(content) as Record<string, unknown>;
187
+ const probes = Array.isArray(data["probes"])
188
+ ? (data["probes"] as Array<Record<string, unknown>>)
189
+ : [];
190
+ const probesTotal = probes.length;
191
+ const probesPassed = probes.filter((p) => p["ok"] === true).length;
192
+ return {
193
+ probesTotal,
194
+ probesPassed,
195
+ evidencePath: `docs/rfcs/verification/rfc-${rfcNum}.generated.yaml`,
196
+ };
197
+ } catch {
198
+ return null;
199
+ }
200
+ }
201
+
202
+ // ─── Acceptance criteria parsing ─────────────────────────────────────────────
203
+
204
+ async function parseAcceptanceCriteria(
205
+ workspaceRoot: string,
206
+ rfcId: string,
207
+ ): Promise<ResultMetrics> {
208
+ const rfcDir = join(workspaceRoot, "docs", "rfcs");
209
+ let files: string[];
210
+ try {
211
+ files = await readdir(rfcDir);
212
+ } catch {
213
+ return { acceptanceCriteriaTotal: 0, acceptanceCriteriaMet: 0 };
214
+ }
215
+
216
+ const rfcLower = rfcId.toLowerCase();
217
+ const rfcFile = files.find(
218
+ (f) => f.toLowerCase().startsWith(rfcLower + "-") || f.toLowerCase().startsWith(rfcLower + "_"),
219
+ );
220
+ if (!rfcFile) {
221
+ return { acceptanceCriteriaTotal: 0, acceptanceCriteriaMet: 0 };
222
+ }
223
+
224
+ let content: string;
225
+ try {
226
+ content = await readFile(join(rfcDir, rfcFile), "utf-8");
227
+ } catch {
228
+ return { acceptanceCriteriaTotal: 0, acceptanceCriteriaMet: 0 };
229
+ }
230
+
231
+ const acSectionMatch = content.match(/##\s*Acceptance\s+criteria[\s\S]*?(?=\n##\s|$)/i);
232
+ if (!acSectionMatch) {
233
+ return { acceptanceCriteriaTotal: 0, acceptanceCriteriaMet: 0 };
234
+ }
235
+
236
+ const acSection = acSectionMatch[0]!;
237
+ const totalMatches = acSection.match(/-\s*\[x\]/gi);
238
+ const uncheckedMatches = acSection.match(/-\s*\[\s\]/g);
239
+ const acceptanceCriteriaMet = totalMatches ? totalMatches.length : 0;
240
+ const acceptanceCriteriaUnmet = uncheckedMatches ? uncheckedMatches.length : 0;
241
+ return {
242
+ acceptanceCriteriaTotal: acceptanceCriteriaMet + acceptanceCriteriaUnmet,
243
+ acceptanceCriteriaMet,
244
+ };
245
+ }
246
+
247
+ // ─── Fix metrics parsing ─────────────────────────────────────────────────────
248
+
249
+ function extractFixMetrics(commits: GitLogEntry[]): FixMetrics | null {
250
+ const fixCommits = commits.filter((c) => c.message.split("\n")[0]!.startsWith("fix:"));
251
+ if (fixCommits.length === 0) return null;
252
+ return {
253
+ fixesApplied: fixCommits.length,
254
+ commitSha: fixCommits[fixCommits.length - 1]!.sha,
255
+ iterations: fixCommits.length,
256
+ };
257
+ }
258
+
259
+ // ─── Timings ─────────────────────────────────────────────────────────────────
260
+
261
+ function computeTimings(commits: GitLogEntry[]): RfcTimings {
262
+ if (commits.length === 0) {
263
+ return { firstCommitAt: null, lastCommitAt: null, totalDurationMs: null };
264
+ }
265
+ const sorted = [...commits].sort((a, b) => a.timestamp.localeCompare(b.timestamp));
266
+ const first = sorted[0]!;
267
+ const last = sorted[sorted.length - 1]!;
268
+ const durationMs = new Date(last.timestamp).getTime() - new Date(first.timestamp).getTime();
269
+ return {
270
+ firstCommitAt: first.timestamp,
271
+ lastCommitAt: last.timestamp,
272
+ totalDurationMs: isNaN(durationMs) ? null : durationMs,
273
+ };
274
+ }
275
+
276
+ // ─── Main function ───────────────────────────────────────────────────────────
277
+
278
+ export async function generateRfcMetrics(
279
+ workspaceRoot: string,
280
+ rfcId: string,
281
+ logger: { warn: (msg: string) => void },
282
+ ): Promise<string> {
283
+ const commits = await getRfcCommits(workspaceRoot, rfcId);
284
+
285
+ const pipeline: RfcPipelineStep[] = [];
286
+ for (const commit of commits) {
287
+ const classified = classifyCommit(commit.message);
288
+ if (classified) {
289
+ pipeline.push({
290
+ step: classified.step,
291
+ skill: classified.skill,
292
+ commitSha: commit.sha,
293
+ timestamp: commit.timestamp,
294
+ });
295
+ }
296
+ }
297
+
298
+ let review: ReviewMetrics | null = null;
299
+ try {
300
+ const reviewContent = await findReviewReport(workspaceRoot, rfcId);
301
+ if (reviewContent) {
302
+ review = parseReviewReport(reviewContent);
303
+ }
304
+ } catch {
305
+ logger.warn(`[metrics] Could not parse review report for ${rfcId}`);
306
+ }
307
+
308
+ if (!review) {
309
+ review = { findingsCount: 0, findingsByAxis: {}, verdict: null };
310
+ }
311
+
312
+ const fix = extractFixMetrics(commits);
313
+ const verification = await parseVerificationEvidence(workspaceRoot, rfcId);
314
+ const result = await parseAcceptanceCriteria(workspaceRoot, rfcId);
315
+ const timings = computeTimings(commits);
316
+
317
+ const metrics: RfcMetrics = {
318
+ rfcId,
319
+ generatedAt: new Date().toISOString(),
320
+ pipeline,
321
+ review,
322
+ fix,
323
+ verification,
324
+ result,
325
+ timings,
326
+ };
327
+
328
+ const yamlContent = yamlStringify(metrics, { lineWidth: 120 });
329
+ const metricsFilePath = join(
330
+ workspaceRoot,
331
+ METRICS_DIR,
332
+ "rfcs",
333
+ `${rfcId.toLowerCase()}.metrics.yaml`,
334
+ );
335
+ await mkdir(dirname(metricsFilePath), { recursive: true });
336
+ await writeFileAtomic(metricsFilePath, yamlContent);
337
+
338
+ return `${METRICS_DIR}/rfcs/${rfcId.toLowerCase()}.metrics.yaml`;
339
+ }
@@ -0,0 +1,209 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>
4
+ RFC-1053: Generate per-session skill effectiveness metrics as structured YAML.
5
+ Extracts skill invocations from parsed ATIF messages (not rendered markdown)
6
+ with approximate durations from message timestamps. Writes to
7
+ docs/metrics/sessions/<session-id>.metrics.yaml via writeFileAtomic.
8
+ </purpose>
9
+ <non-goals>
10
+ <item>Does not parse the rendered markdown transcript — uses AtifMessage[] directly.</item>
11
+ <item>Does not block session.save on metrics failure — caller wraps in try/catch.</item>
12
+ <item>Does not extract skill durations with precision — all durations are approximate.</item>
13
+ </non-goals>
14
+ </MODULE_CONTRACT>
15
+ <CHANGE_SUMMARY>
16
+ <item>RFC-1053: initial generateSessionMetrics pure function using ATIF metadata.</item>
17
+ </CHANGE_SUMMARY>
18
+ */
19
+
20
+ import { readFile, readdir, mkdir } from "node:fs/promises";
21
+ import { join, dirname } from "node:path";
22
+
23
+ import { writeFileAtomic } from "../../../src/utils/fs-atomic.ts";
24
+ import { stringify as yamlStringify } from "yaml";
25
+
26
+ import { METRICS_DIR, type SessionMetrics, type SessionSkillInvocation, type SessionDocumentRef, type InsightSummary } from "../types.ts";
27
+ import type { AtifMessage } from "../atif-parser.ts";
28
+
29
+ // ─── Skill name extraction ───────────────────────────────────────────────────
30
+
31
+ const SKILL_PATTERN = /\b(fo-[a-z]+(?:-[a-z]+)*)\b/g;
32
+
33
+ const KNOWN_SKILLS = new Set([
34
+ "fo-idea",
35
+ "fo-idea-audit",
36
+ "fo-idea-enhance",
37
+ "fo-idea-plan",
38
+ "fo-idea-implement",
39
+ "fo-idea-create-rfc",
40
+ "fo-idea-create-adr",
41
+ "fo-idea-i-just-want-to-see-the-plan",
42
+ "fo-idea-i-just-want-to-see-the-result",
43
+ "fo-idea-status",
44
+ "fo-idea-audit",
45
+ "fo-review",
46
+ "fo-fix",
47
+ "fo-session-retro",
48
+ "fo-session-save",
49
+ "fo-doc-audit",
50
+ "fo-architecture",
51
+ "fo-compass-annotate",
52
+ "fo-explore",
53
+ "fo-extract-dna",
54
+ "fo-handoff",
55
+ "fo-harvest",
56
+ "fo-knowledge-distill",
57
+ "fo-memory-sync",
58
+ "fo-qa",
59
+ "fo-step-commit",
60
+ "fo-triage",
61
+ "fo-add-tests",
62
+ ]);
63
+
64
+ function extractSkillInvocations(messages: AtifMessage[]): SessionSkillInvocation[] {
65
+ const invocations: SessionSkillInvocation[] = [];
66
+ const seenSkills = new Map<string, { firstMsgIndex: number; lastMsgIndex: number }>();
67
+
68
+ for (let i = 0; i < messages.length; i++) {
69
+ const msg = messages[i]!;
70
+ const matches = [...msg.content.matchAll(SKILL_PATTERN)];
71
+ const skillNames = new Set(matches.map((m) => m[1]!));
72
+ for (const skill of skillNames) {
73
+ if (!KNOWN_SKILLS.has(skill)) continue;
74
+ const existing = seenSkills.get(skill);
75
+ if (existing) {
76
+ existing.lastMsgIndex = i;
77
+ } else {
78
+ seenSkills.set(skill, { firstMsgIndex: i, lastMsgIndex: i });
79
+ }
80
+ }
81
+ }
82
+
83
+ for (const [skill, indices] of seenSkills) {
84
+ const firstMsg = messages[indices.firstMsgIndex]!;
85
+ const lastMsg = messages[indices.lastMsgIndex]!;
86
+ let approximateDurationMs: number | null = null;
87
+
88
+ if (firstMsg.timestamp && lastMsg.timestamp) {
89
+ const firstTime = new Date(firstMsg.timestamp).getTime();
90
+ const lastTime = new Date(lastMsg.timestamp).getTime();
91
+ if (!isNaN(firstTime) && !isNaN(lastTime)) {
92
+ approximateDurationMs = Math.max(0, lastTime - firstTime);
93
+ }
94
+ }
95
+
96
+ invocations.push({ skill, approximateDurationMs });
97
+ }
98
+
99
+ return invocations;
100
+ }
101
+
102
+ // ─── Document references ─────────────────────────────────────────────────────
103
+
104
+ async function extractDocumentRefs(
105
+ workspaceRoot: string,
106
+ relatedRfcs: string[],
107
+ ): Promise<SessionDocumentRef[]> {
108
+ const refs: SessionDocumentRef[] = [];
109
+ const metricsRfcDir = join(workspaceRoot, METRICS_DIR, "rfcs");
110
+
111
+ let existingMetricsFiles: string[] = [];
112
+ try {
113
+ existingMetricsFiles = await readdir(metricsRfcDir);
114
+ } catch {
115
+ // No metrics directory yet
116
+ }
117
+
118
+ for (const rfcId of relatedRfcs) {
119
+ const rfcLower = rfcId.toLowerCase();
120
+ const metricsFileName = `${rfcLower}.metrics.yaml`;
121
+ const hasMetrics = existingMetricsFiles.includes(metricsFileName);
122
+ refs.push({
123
+ rfcId,
124
+ status: "unknown",
125
+ metricsFile: hasMetrics ? `${METRICS_DIR}/rfcs/${metricsFileName}` : null,
126
+ });
127
+ }
128
+
129
+ return refs;
130
+ }
131
+
132
+ // ─── Insight extraction ──────────────────────────────────────────────────────
133
+
134
+ function extractInsights(messages: AtifMessage[]): InsightSummary | null {
135
+ let total = 0;
136
+ const byCategory: Record<string, number> = {};
137
+
138
+ for (const msg of messages) {
139
+ const lowerContent = msg.content.toLowerCase();
140
+ if (!lowerContent.includes("insight")) continue;
141
+
142
+ const categoryPattern = /(?:category|type|route):\s*([a-z-]+)/gi;
143
+ let match;
144
+ while ((match = categoryPattern.exec(msg.content)) !== null) {
145
+ const category = match[1]!.trim();
146
+ total++;
147
+ byCategory[category] = (byCategory[category] ?? 0) + 1;
148
+ }
149
+ }
150
+
151
+ if (total === 0) return null;
152
+ return { total, byCategory };
153
+ }
154
+
155
+ // ─── Duration calculation ────────────────────────────────────────────────────
156
+
157
+ function computeSessionDurationMs(messages: AtifMessage[]): number {
158
+ const timestamps = messages
159
+ .map((m) => (m.timestamp ? new Date(m.timestamp).getTime() : null))
160
+ .filter((t): t is number => t !== null && !isNaN(t));
161
+
162
+ if (timestamps.length < 2) return 0;
163
+ const min = Math.min(...timestamps);
164
+ const max = Math.max(...timestamps);
165
+ return Math.max(0, max - min);
166
+ }
167
+
168
+ // ─── Main function ───────────────────────────────────────────────────────────
169
+
170
+ export async function generateSessionMetrics(
171
+ workspaceRoot: string,
172
+ sessionId: string,
173
+ messages: AtifMessage[],
174
+ sessionFrontmatter: {
175
+ date?: string;
176
+ relatedRfcs?: string[];
177
+ commits?: string[];
178
+ },
179
+ logger: { warn: (msg: string) => void },
180
+ ): Promise<string> {
181
+ const skills = extractSkillInvocations(messages);
182
+ const documents = await extractDocumentRefs(workspaceRoot, sessionFrontmatter.relatedRfcs ?? []);
183
+ const insights = extractInsights(messages);
184
+ const durationMs = computeSessionDurationMs(messages);
185
+
186
+ const metrics: SessionMetrics = {
187
+ sessionId,
188
+ generatedAt: new Date().toISOString(),
189
+ date: sessionFrontmatter.date ?? new Date().toISOString().split("T")[0]!,
190
+ durationMs,
191
+ approximateDurations: true,
192
+ documents,
193
+ skills,
194
+ insights,
195
+ commits: sessionFrontmatter.commits ?? [],
196
+ };
197
+
198
+ const yamlContent = yamlStringify(metrics, { lineWidth: 120 });
199
+ const metricsFilePath = join(
200
+ workspaceRoot,
201
+ METRICS_DIR,
202
+ "sessions",
203
+ `${sessionId}.metrics.yaml`,
204
+ );
205
+ await mkdir(dirname(metricsFilePath), { recursive: true });
206
+ await writeFileAtomic(metricsFilePath, yamlContent);
207
+
208
+ return `${METRICS_DIR}/sessions/${sessionId}.metrics.yaml`;
209
+ }
@@ -13,6 +13,7 @@ Deterministic only — no LLM/intelligent annotation (that is fo-session-save sk
13
13
  <CHANGE_SUMMARY>
14
14
  <item>RFC-0537: implement session.save command handler.</item>
15
15
  <item>Replace fs.unlink with trashPath for raw file deletion (trash bin for LLM-initiated deletions).</item>
16
+ <item>RFC-1053: integrate generateSessionMetrics after markdown write (non-fatal, guarded by !dryRun).</item>
16
17
  </CHANGE_SUMMARY>
17
18
  */
18
19
 
@@ -27,6 +28,7 @@ import type {
27
28
  } from "../../../src/types.ts";
28
29
  import { parseAtif, messagesToTranscriptMarkdown } from "../atif-parser.ts";
29
30
  import { trashPath } from "../../../src/utils/fs-trash.ts";
31
+ import { generateSessionMetrics } from "./metrics-session.ts";
30
32
  import {
31
33
  SESSION_DIR,
32
34
  SESSION_RAW_SUBDIR,
@@ -289,10 +291,26 @@ export async function runSessionSave(
289
291
  transcriptText,
290
292
  );
291
293
 
294
+ let metricsPath: string | undefined;
292
295
  if (!dryRun) {
293
296
  await fs.mkdir(sessionDirPath, { recursive: true });
294
297
  await fs.writeFile(outputPath, markdown, "utf-8");
295
298
 
299
+ // ── RFC-1053: Generate session metrics from ATIF messages (non-fatal) ──
300
+ try {
301
+ metricsPath = await generateSessionMetrics(
302
+ workspaceRoot,
303
+ id,
304
+ atifResult.messages,
305
+ { date: timestamp.toISOString(), relatedRfcs, commits },
306
+ logger,
307
+ );
308
+ } catch (metricsErr) {
309
+ logger.warn(
310
+ `[metrics] Failed to generate session metrics for ${id}: ${(metricsErr as Error).message}`,
311
+ );
312
+ }
313
+
296
314
  // Delete raw file unless --keep-raw
297
315
  if (!keepRaw) {
298
316
  try {
@@ -320,6 +338,7 @@ export async function runSessionSave(
320
338
  files,
321
339
  commands,
322
340
  },
341
+ ...(metricsPath ? { metricsPath } : {}),
323
342
  dryRun,
324
343
  };
325
344
  saved.push(result);
@@ -7,10 +7,11 @@
7
7
  </MODULE_CONTRACT>
8
8
  <CHANGE_SUMMARY>
9
9
  <item>RFC-0537: initial session module barrel.</item>
10
+ <item>RFC-1053: export metrics types and METRICS_DIR constant.</item>
10
11
  </CHANGE_SUMMARY>
11
12
  */
12
13
 
13
- export { forgeSessionModule } from "./session.module.ts";
14
+ export { createForgeSessionModule } from "./session.module.ts";
14
15
  export { runSessionSave } from "./handlers/save.ts";
15
16
  export { runSessionArchive } from "./handlers/archive.ts";
16
17
  export { runSessionValidate } from "./handlers/validate.ts";
@@ -34,6 +35,7 @@ export {
34
35
  SESSION_DIR,
35
36
  SESSION_RAW_SUBDIR,
36
37
  SESSION_ARCHIVE_SUBDIR,
38
+ METRICS_DIR,
37
39
  SESSION_TYPES,
38
40
  SES_RULES,
39
41
  type SessionType,
@@ -47,4 +49,17 @@ export {
47
49
  type SessionListResult,
48
50
  type SessionListEntry,
49
51
  type SesRule,
52
+ type RfcMetrics,
53
+ type RfcPipelineStep,
54
+ type ReviewMetrics,
55
+ type FixMetrics,
56
+ type VerificationMetrics,
57
+ type ResultMetrics,
58
+ type RfcTimings,
59
+ type SessionMetrics,
60
+ type SessionDocumentRef,
61
+ type SessionSkillInvocation,
62
+ type InsightSummary,
63
+ type SkillAggregate,
64
+ type MetricsAggregateResult,
50
65
  } from "./types.ts";