@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.
- package/AGENTS.md +18 -3
- package/bin/cli.ts +15 -8
- package/os/adr/adr.module.ts +24 -23
- package/os/adr/index.ts +1 -1
- package/os/audit/audit.module.ts +13 -8
- package/os/audit/index.ts +1 -1
- package/os/compass/compass.module.ts +22 -19
- package/os/core/core.module.ts +842 -864
- package/os/core/handlers/package-health.ts +20 -0
- package/os/core/index.ts +1 -1
- package/os/exploration/exploration.module.ts +19 -16
- package/os/exploration/index.ts +2 -2
- package/os/mission/index.ts +1 -1
- package/os/mission/mission.module.ts +13 -8
- package/os/naming/index.ts +1 -1
- package/os/naming/naming-convention.ts +1 -0
- package/os/naming/naming.module.ts +13 -7
- package/os/notes/index.ts +2 -2
- package/os/notes/notes.module.ts +20 -17
- package/os/plan/index.ts +1 -1
- package/os/plan/plan.module.ts +13 -8
- package/os/plugin/plugin.module.ts +10 -8
- package/os/program/program.module.ts +22 -20
- package/os/rfc/handlers/implement-stamp.ts +17 -1
- package/os/rfc/index.ts +1 -1
- package/os/rfc/rfc-0000-template.md +3 -2
- package/os/rfc/rfc.module.ts +54 -84
- package/os/rfc/types.ts +1 -0
- package/os/session/handlers/metrics-aggregate.ts +208 -0
- package/os/session/handlers/metrics-rfc.ts +339 -0
- package/os/session/handlers/metrics-session.ts +209 -0
- package/os/session/handlers/save.ts +19 -0
- package/os/session/index.ts +16 -1
- package/os/session/session.module.ts +133 -107
- package/os/session/types.ts +99 -0
- package/os/spec/spec.module.ts +28 -29
- package/os/werkstatt/werkstatt.module.ts +12 -9
- package/os/workflow/index.ts +1 -1
- package/os/workflow/workflow.module.ts +18 -12
- package/package.json +3 -1
- package/src/forge-module.ts +24 -10
- package/src/index.ts +12 -12
- package/src/onboarding/scaffold.ts +6 -4
- package/src/tests/metrics-rfc-1053.test.ts +355 -0
- package/src/types/werkstatt-engine-shims.d.ts +126 -48
- 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);
|
package/os/session/index.ts
CHANGED
|
@@ -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 {
|
|
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";
|