portable-agent-layer 0.3.0 → 0.4.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
@@ -48,6 +48,7 @@
48
48
  "ai:fyzz-api": "bun run src/tools/fyzz-api.ts",
49
49
  "ai:pdf-download": "bun run src/tools/pdf-download.ts",
50
50
  "ai:youtube-analyze": "bun run src/tools/youtube-analyze.ts",
51
+ "tool:graduate": "bun run src/tools/graduate.ts",
51
52
  "tool:patterns": "bun run src/tools/pattern-synthesis.ts",
52
53
  "tool:reflect": "bun run src/tools/relationship-reflect.ts",
53
54
  "tool:export": "bun run src/tools/export.ts",
package/src/cli/index.ts CHANGED
@@ -21,6 +21,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "node
21
21
  import { homedir } from "node:os";
22
22
  import { resolve } from "node:path";
23
23
  import { palHome, palPkg, platform } from "../hooks/lib/paths";
24
+ import { getPendingSuggestions } from "../hooks/lib/tags";
24
25
  import { log } from "../targets/lib";
25
26
 
26
27
  const allArgs = process.argv.slice(2);
@@ -252,6 +253,43 @@ function resolveTargets(
252
253
  return targets;
253
254
  }
254
255
 
256
+ // ── Hook health ──
257
+
258
+ interface HookHealth {
259
+ totalErrors: number;
260
+ lastError: string | null;
261
+ }
262
+
263
+ function checkHookHealth(home: string): HookHealth {
264
+ const logPath = resolve(home, "memory", "state", "debug.log");
265
+
266
+ try {
267
+ if (!existsSync(logPath)) return { totalErrors: 0, lastError: null };
268
+
269
+ const content = readFileSync(logPath, "utf-8");
270
+ const lines = content.split("\n").filter((l) => l.includes("] ERROR "));
271
+
272
+ // Filter to last 24h
273
+ const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000);
274
+ const recentErrors = lines.filter((line) => {
275
+ const match = line.match(/^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]/);
276
+ if (!match) return false;
277
+ return new Date(match[1]) > cutoff;
278
+ });
279
+
280
+ const lastError =
281
+ recentErrors.length > 0
282
+ ? recentErrors[recentErrors.length - 1]
283
+ .replace(/^\[.*?\] ERROR /, "")
284
+ .slice(0, 120)
285
+ : null;
286
+
287
+ return { totalErrors: recentErrors.length, lastError };
288
+ } catch {
289
+ return { totalErrors: 0, lastError: null };
290
+ }
291
+ }
292
+
255
293
  // ── Doctor ──
256
294
 
257
295
  interface DoctorResult {
@@ -288,8 +326,9 @@ function doctor(silent = false): DoctorResult {
288
326
  })();
289
327
 
290
328
  if (!silent) {
291
- const ok = (msg: string) => log.info(` \u2713 ${msg}`);
292
- const fail = (msg: string) => log.warn(` \u2717 ${msg}`);
329
+ const ok = (msg: string) => console.log(` \x1b[32m\u2713\x1b[0m ${msg}`);
330
+ const warn = (msg: string) => console.log(` \x1b[33m\u26A0\x1b[0m ${msg}`);
331
+ const fail = (msg: string) => console.log(` \x1b[31m\u2717\x1b[0m ${msg}`);
293
332
 
294
333
  console.log("");
295
334
  log.info("Doctor");
@@ -303,6 +342,37 @@ function doctor(silent = false): DoctorResult {
303
342
  ok(`PAL home: ${home} (${isRepo ? "repo" : "package"} mode)`);
304
343
  telosCount > 0 ? ok(`TELOS: ${telosCount} files`) : fail("TELOS: not scaffolded");
305
344
 
345
+ // API key checks
346
+ process.env.ANTHROPIC_API_KEY
347
+ ? ok("ANTHROPIC_API_KEY is set")
348
+ : fail("ANTHROPIC_API_KEY — not set (hooks need it for inference)");
349
+ process.env.GEMINI_API_KEY
350
+ ? ok("GEMINI_API_KEY is set")
351
+ : warn("GEMINI_API_KEY — not set (optional, for YouTube analysis)");
352
+
353
+ // Hook health from debug.log
354
+ const hookHealth = checkHookHealth(home);
355
+ if (hookHealth.totalErrors === 0) {
356
+ ok("Hooks: no recent errors");
357
+ } else {
358
+ fail(`Hooks: ${hookHealth.totalErrors} error(s) in last 24h`);
359
+ if (hookHealth.lastError) {
360
+ log.warn(` Last: ${hookHealth.lastError}`);
361
+ }
362
+ }
363
+
364
+ // Pending tag suggestions
365
+ const pending = getPendingSuggestions();
366
+ const pendingEntries = Object.entries(pending).sort((a, b) => b[1] - a[1]);
367
+ if (pendingEntries.length > 0) {
368
+ warn(`Tags: ${pendingEntries.length} pending suggestion(s)`);
369
+ for (const [tag, count] of pendingEntries.slice(0, 5)) {
370
+ log.info(` "${tag}" (${count}/3 to promote)`);
371
+ }
372
+ } else {
373
+ ok("Tags: no pending suggestions");
374
+ }
375
+
306
376
  if (!hasAgent) {
307
377
  console.log("");
308
378
  log.error("No supported agent found. Install Claude Code or opencode.");
@@ -2,14 +2,16 @@
2
2
  * Deep Failure Capture — full context dump for ratings 1–3.
3
3
  *
4
4
  * Writes to memory/learning/failures/YYYY-MM/{timestamp}_{slug}/
5
- * CONTEXT.md full failure context with transcript excerpt
6
- * sentiment.json — structured rating + metadata
5
+ * capture.md frontmatter metadata + failure context body
6
+ * sentiment.json — DEPRECATED legacy format (kept for backward compat)
7
7
  */
8
8
 
9
9
  import { writeFileSync } from "node:fs";
10
10
  import { resolve } from "node:path";
11
+ import { stringify } from "../lib/frontmatter";
11
12
  import { inference } from "../lib/inference";
12
13
  import { ensureDir, paths } from "../lib/paths";
14
+ import { getVocabulary, recordSuggestedTag } from "../lib/tags";
13
15
  import { fileTimestamp, monthPath } from "../lib/time";
14
16
  import { logTokenUsage } from "../lib/token-usage";
15
17
  import {
@@ -53,32 +55,38 @@ export async function captureFailure(
53
55
  resolve(paths.failures(), monthPath(), `${fileTimestamp()}_${slug}`)
54
56
  );
55
57
 
56
- // Attempt inference to fill root cause analysis
58
+ // Attempt inference to fill root cause analysis + tags
57
59
  let whatWentWrong = "";
58
60
  let whatToDoDifferently = "";
61
+ let tags: string[] = [];
59
62
  try {
63
+ const vocab = getVocabulary();
60
64
  const analysisResult = await inference({
61
- system:
62
- "You are analyzing a failed AI assistant interaction. Based on the context, identify what went wrong and what should be done differently. Be specific and actionable.",
65
+ system: `You are analyzing a failed AI assistant interaction. Based on the context, identify what went wrong and what should be done differently. Be specific and actionable. Also pick 1-3 tags from this list: [${vocab.join(", ")}]. If none fit, leave tags empty and put your suggested tag in suggested_tag.`,
63
66
  user: [
64
67
  `Rating: ${rating}/10`,
65
68
  `Context: ${context}`,
66
69
  detailedContext ? `Analysis: ${detailedContext}` : "",
67
- `User said: ${lastUser}`,
68
- `Assistant said: ${lastAssistant}`,
70
+ `Assistant response (what the user reacted to): ${lastAssistant}`,
71
+ `User reaction (the frustrated message): ${lastUser}`,
69
72
  ]
70
73
  .filter(Boolean)
71
74
  .join("\n"),
72
- maxTokens: 300,
73
- timeout: 8000,
75
+ maxTokens: 400,
76
+ timeout: 15000,
74
77
  jsonSchema: {
75
78
  type: "object" as const,
76
79
  additionalProperties: false,
77
80
  properties: {
78
81
  what_went_wrong: { type: "string" as const },
79
82
  what_to_do_differently: { type: "string" as const },
83
+ tags: {
84
+ type: "array" as const,
85
+ items: { type: "string" as const },
86
+ },
87
+ suggested_tag: { type: "string" as const },
80
88
  },
81
- required: ["what_went_wrong", "what_to_do_differently"],
89
+ required: ["what_went_wrong", "what_to_do_differently", "tags"],
82
90
  },
83
91
  });
84
92
  if (analysisResult.usage) logTokenUsage("failure", analysisResult.usage);
@@ -86,51 +94,48 @@ export async function captureFailure(
86
94
  const parsed = JSON.parse(analysisResult.output) as {
87
95
  what_went_wrong?: string;
88
96
  what_to_do_differently?: string;
97
+ tags?: string[];
98
+ suggested_tag?: string;
89
99
  };
90
100
  whatWentWrong = parsed.what_went_wrong ?? "";
91
101
  whatToDoDifferently = parsed.what_to_do_differently ?? "";
102
+ if (parsed.tags?.length) tags = parsed.tags;
103
+ if (parsed.suggested_tag) recordSuggestedTag(parsed.suggested_tag);
92
104
  }
93
105
  } catch {
94
106
  // Graceful fallback — empty sections are still useful with the other context
95
107
  }
96
108
 
97
- const contextMdPath = resolve(dir, "CONTEXT.md");
98
- writeFileSync(
99
- contextMdPath,
100
- [
101
- `# Failure Capture — Rating ${rating}/10`,
102
- `**Date:** ${new Date().toISOString().slice(0, 10)}`,
103
- `**Context:** ${context}`,
104
- "",
105
- "## Last User Message",
106
- lastUser || "*(unavailable)*",
107
- "",
108
- "## Last Assistant Response",
109
- lastAssistant || "*(unavailable)*",
110
- "",
111
- ...(detailedContext ? ["## AI Response Context", detailedContext, ""] : []),
112
- "## What Went Wrong?",
113
- whatWentWrong || "",
114
- "",
115
- "## What Should Be Done Differently?",
116
- whatToDoDifferently || "",
117
- "",
118
- ].join("\n"),
119
- "utf-8"
120
- );
109
+ const meta: Record<string, unknown> = {
110
+ rating,
111
+ context,
112
+ date: new Date().toISOString().slice(0, 10),
113
+ ts: new Date().toISOString(),
114
+ slug,
115
+ };
116
+ if (tags.length > 0) meta.tags = tags;
121
117
 
118
+ const body = [
119
+ "## Last User Message",
120
+ lastUser || "*(unavailable)*",
121
+ "",
122
+ "## Last Assistant Response",
123
+ lastAssistant || "*(unavailable)*",
124
+ "",
125
+ ...(detailedContext ? ["## AI Response Context", detailedContext, ""] : []),
126
+ "## What Went Wrong?",
127
+ whatWentWrong || "",
128
+ "",
129
+ "## What Should Be Done Differently?",
130
+ whatToDoDifferently || "",
131
+ ].join("\n");
132
+
133
+ writeFileSync(resolve(dir, "capture.md"), stringify(meta, body), "utf-8");
134
+
135
+ // DEPRECATED: legacy sentiment.json — remove once all readers use capture.md frontmatter
122
136
  writeFileSync(
123
137
  resolve(dir, "sentiment.json"),
124
- JSON.stringify(
125
- {
126
- rating,
127
- context,
128
- ts: new Date().toISOString(),
129
- slug,
130
- },
131
- null,
132
- 2
133
- ),
138
+ JSON.stringify({ rating, context, ts: new Date().toISOString(), slug }, null, 2),
134
139
  "utf-8"
135
140
  );
136
141
  }
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
12
12
  import { resolve } from "node:path";
13
+ import { stringify } from "../lib/frontmatter";
13
14
  import { inference } from "../lib/inference";
14
15
  import { categorizeLearning } from "../lib/learning-category";
15
16
  import { ensureDir, paths } from "../lib/paths";
@@ -244,24 +245,24 @@ function writeLearningMarkdown(
244
245
  const dir = ensureDir(resolve(paths.sessionLearning(), monthPath()));
245
246
  const filename = `${fileTimestamp()}_${source}-rating-${rating}_${category}.md`;
246
247
 
247
- const content = [
248
- `# ${source === "explicit" ? "Low Rating" : "Implicit Low Rating"}: ${rating}/10`,
249
- `**Title:** ${context.slice(0, 100) || "(low rating)"}`,
250
- `**Date:** ${new Date().toISOString().slice(0, 10)}`,
251
- `**Rating:** ${rating}/10`,
252
- `**Source:** ${source}`,
253
- `**Category:** ${category.toUpperCase()}`,
254
- "",
248
+ const meta: Record<string, unknown> = {
249
+ title: context.slice(0, 100) || "(low rating)",
250
+ category,
251
+ date: new Date().toISOString().slice(0, 10),
252
+ rating,
253
+ source,
254
+ };
255
+
256
+ const body = [
255
257
  "## Context",
256
258
  context || "*(unavailable)*",
257
259
  "",
258
260
  ...(detailedContext ? ["## Analysis", detailedContext, ""] : []),
259
261
  "## Last Response",
260
262
  responsePreview || "*(unavailable)*",
261
- "",
262
263
  ].join("\n");
263
264
 
264
- writeFileSync(resolve(dir, filename), content, "utf-8");
265
+ writeFileSync(resolve(dir, filename), stringify(meta, body), "utf-8");
265
266
  }
266
267
 
267
268
  function handleRating(
@@ -295,14 +296,7 @@ function handleRating(
295
296
  ),
296
297
  "utf-8"
297
298
  );
298
- // Also write learning markdown
299
- writeLearningMarkdown(
300
- rating,
301
- source,
302
- context,
303
- detailedContext ?? "",
304
- responsePreview
305
- );
299
+ // No learning markdown for ≤3 — failure capture covers it with richer analysis + tags
306
300
  } else if (rating < 5) {
307
301
  // Low but not critical — write learning markdown
308
302
  writeLearningMarkdown(
@@ -7,9 +7,11 @@
7
7
 
8
8
  import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
9
9
  import { resolve } from "node:path";
10
+ import { stringify } from "../lib/frontmatter";
10
11
  import { inference } from "../lib/inference";
11
12
  import { categorizeLearning } from "../lib/learning-category";
12
13
  import { ensureDir, paths } from "../lib/paths";
14
+ import { getVocabulary, recordSuggestedTag } from "../lib/tags";
13
15
  import { fileTimestamp, monthPath } from "../lib/time";
14
16
  import { logTokenUsage } from "../lib/token-usage";
15
17
  import {
@@ -106,18 +108,19 @@ export async function captureWorkLearning(
106
108
  let title = rawTitle;
107
109
  let summary = rawSummary;
108
110
  let insights = "";
111
+ let tags: string[] = [];
109
112
  try {
113
+ const vocab = getVocabulary();
110
114
  const userMessages = messages
111
115
  .filter((m) => m.role === "user")
112
116
  .map((m) => extractContent(m).slice(0, 100))
113
117
  .slice(-8)
114
118
  .join("\n");
115
119
  const result = await inference({
116
- system:
117
- "You summarize AI coding sessions between a human user and an AI assistant. The 'Human messages' are what the user said. The 'AI response' is what the assistant said. Produce: 1) a short title (5-10 words) describing what was accomplished, 2) a summary of what the AI assistant did for the user (2-4 sentences, write from the AI's perspective using 'we'), 3) insights — what worked well, what was surprising, or what should be done differently next time (2-3 bullet points, no markdown).",
120
+ system: `You summarize AI coding sessions between a human user and an AI assistant. The 'Human messages' are what the user said. The 'AI response' is what the assistant said. Produce: 1) a short title (5-10 words) describing what was accomplished, 2) a summary of what the AI assistant did for the user (2-4 sentences, write from the AI's perspective using 'we'), 3) insights — what worked well, what was surprising, or what should be done differently next time (2-3 bullet points, no markdown), 4) tags — pick 1-3 from this list: [${vocab.join(", ")}]. If none fit, leave tags empty and put your suggested tag in suggested_tag.`,
118
121
  user: `Human messages:\n${userMessages}\n\nAI response:\n${rawSummary.slice(0, 400)}`,
119
- maxTokens: 250,
120
- timeout: 8000,
122
+ maxTokens: 350,
123
+ timeout: 15000,
121
124
  jsonSchema: {
122
125
  type: "object" as const,
123
126
  additionalProperties: false,
@@ -125,8 +128,13 @@ export async function captureWorkLearning(
125
128
  title: { type: "string" as const },
126
129
  summary: { type: "string" as const },
127
130
  insights: { type: "string" as const },
131
+ tags: {
132
+ type: "array" as const,
133
+ items: { type: "string" as const },
134
+ },
135
+ suggested_tag: { type: "string" as const },
128
136
  },
129
- required: ["title", "summary", "insights"],
137
+ required: ["title", "summary", "insights", "tags"],
130
138
  },
131
139
  });
132
140
  if (result.usage) logTokenUsage("work-learning", result.usage);
@@ -135,10 +143,14 @@ export async function captureWorkLearning(
135
143
  title?: string;
136
144
  summary?: string;
137
145
  insights?: string;
146
+ tags?: string[];
147
+ suggested_tag?: string;
138
148
  };
139
149
  if (parsed.title) title = parsed.title.slice(0, 100);
140
150
  if (parsed.summary) summary = parsed.summary;
141
151
  if (parsed.insights) insights = parsed.insights;
152
+ if (parsed.tags?.length) tags = parsed.tags;
153
+ if (parsed.suggested_tag) recordSuggestedTag(parsed.suggested_tag);
142
154
  }
143
155
  } catch {
144
156
  // Fallback to raw values
@@ -149,21 +161,24 @@ export async function captureWorkLearning(
149
161
  const dir = ensureDir(resolve(paths.sessionLearning(), monthPath()));
150
162
  const filename = `${fileTimestamp()}_${category}_${slug}.md`;
151
163
 
152
- const content = [
153
- "# Work Completion Learning",
154
- `**Title:** ${title}`,
155
- `**Category:** ${category.toUpperCase()}`,
156
- `**Date:** ${new Date().toISOString().slice(0, 10)}`,
157
- ...(sessionId ? [`**Session:** ${sessionId}`] : []),
158
- "",
164
+ const meta: Record<string, unknown> = {
165
+ title,
166
+ category,
167
+ date: new Date().toISOString().slice(0, 10),
168
+ };
169
+ if (tags.length > 0) meta.tags = tags;
170
+ if (sessionId) meta.session = sessionId;
171
+
172
+ const body = [
159
173
  "## What Was Done",
160
174
  summary,
161
175
  "",
162
176
  "## Insights",
163
177
  insights || "*No insights captured.*",
164
- "",
165
178
  ].join("\n");
166
179
 
180
+ const content = stringify(meta, body);
181
+
167
182
  // Remove previous capture for this session (overwrite on continued conversations)
168
183
  if (sessionId) {
169
184
  const prev = getPreviousCapture(sessionId);
@@ -5,6 +5,7 @@
5
5
 
6
6
  import { existsSync, readdirSync, readFileSync } from "node:fs";
7
7
  import { resolve } from "node:path";
8
+ import { hasFrontmatter, parse } from "./frontmatter";
8
9
  import { paths } from "./paths";
9
10
  import { loadRecentNotes } from "./relationship";
10
11
  import { readSessionNames } from "./session-names";
@@ -247,28 +248,53 @@ export function loadLearningDigest(): string {
247
248
  if (files.length >= 6) break;
248
249
  }
249
250
 
250
- function extractTitle(filePath: string): string {
251
+ function extractMeta(filePath: string): {
252
+ title: string;
253
+ category: string;
254
+ } {
251
255
  const content = readFileSync(filePath, "utf-8").trim();
256
+
257
+ // Frontmatter format (new)
258
+ if (hasFrontmatter(content)) {
259
+ const { meta } = parse<{ title?: string; category?: string }>(content);
260
+ return {
261
+ title: meta.title ? `**Title:** ${meta.title}` : content.slice(0, 80),
262
+ category: meta.category || "algorithm",
263
+ };
264
+ }
265
+
266
+ // DEPRECATED: legacy **Title:** inline format — remove once old learning files are migrated
252
267
  const titleLine = content.split("\n").find((l) => l.startsWith("**Title:**"));
253
- if (titleLine) return titleLine;
254
- // Fallback: first non-heading, non-empty line
255
268
  const fallback = content.split("\n").find((l) => l.trim() && !l.startsWith("#"));
256
- return fallback?.slice(0, 100) ?? content.slice(0, 80);
269
+ return {
270
+ title: titleLine ?? fallback?.slice(0, 100) ?? content.slice(0, 80),
271
+ category: "algorithm", // legacy files use filename for category
272
+ };
257
273
  }
258
274
 
259
- const algorithm = files.filter((f) => f.category === "algorithm").slice(0, 2);
260
- const system = files.filter((f) => f.category === "system").slice(0, 2);
275
+ // Extract metadata, preferring frontmatter over filename for category
276
+ const enriched = files.map((f) => {
277
+ const meta = extractMeta(f.path);
278
+ return {
279
+ ...f,
280
+ title: meta.title,
281
+ category: meta.category !== "algorithm" ? meta.category : f.category,
282
+ };
283
+ });
284
+
285
+ const algorithm = enriched.filter((f) => f.category === "algorithm").slice(0, 2);
286
+ const system = enriched.filter((f) => f.category === "system").slice(0, 2);
261
287
 
262
288
  if (algorithm.length === 0 && system.length === 0) return "";
263
289
 
264
290
  const lines: string[] = ["## Recent Session Learnings"];
265
291
  if (algorithm.length > 0) {
266
292
  lines.push("### Approach");
267
- for (const f of algorithm) lines.push(`- ${extractTitle(f.path)}`);
293
+ for (const f of algorithm) lines.push(`- ${f.title}`);
268
294
  }
269
295
  if (system.length > 0) {
270
296
  lines.push("### System");
271
- for (const f of system) lines.push(`- ${extractTitle(f.path)}`);
297
+ for (const f of system) lines.push(`- ${f.title}`);
272
298
  }
273
299
  return lines.join("\n");
274
300
  } catch {
@@ -292,22 +318,38 @@ export function loadFailurePatterns(): string {
292
318
  const dirs = readdirSync(monthPath).sort().reverse();
293
319
  for (const dir of dirs) {
294
320
  if (!/^\d{8}-\d{6}_/.test(dir)) continue;
295
- // Read context from sentiment.json for a meaningful description
321
+ // Try capture.md (new format), fall back to sentiment.json (legacy)
322
+ const capturePath = resolve(monthPath, dir, "capture.md");
296
323
  const sentimentPath = resolve(monthPath, dir, "sentiment.json");
297
- if (existsSync(sentimentPath)) {
324
+
325
+ let rating: number | undefined;
326
+ let ctx: string | undefined;
327
+
328
+ if (existsSync(capturePath)) {
329
+ try {
330
+ const content = readFileSync(capturePath, "utf-8");
331
+ const { meta } = parse<{ rating?: number; context?: string }>(content);
332
+ rating = meta.rating;
333
+ ctx = meta.context;
334
+ } catch {
335
+ /* fallback below */
336
+ }
337
+ }
338
+
339
+ // DEPRECATED: legacy sentiment.json fallback — remove once old failures have capture.md
340
+ if (!ctx && existsSync(sentimentPath)) {
298
341
  try {
299
- const data = JSON.parse(readFileSync(sentimentPath, "utf-8")) as {
300
- rating?: number;
301
- context?: string;
302
- };
303
- if (data.context) {
304
- const label = data.rating ? `[${data.rating}/10]` : "";
305
- failures.push(`${label} ${data.context}`.trim());
306
- }
342
+ const data = JSON.parse(readFileSync(sentimentPath, "utf-8"));
343
+ rating = data.rating;
344
+ ctx = data.context;
307
345
  } catch {
308
- // Fall back to slug from directory name
309
- failures.push(dir.replace(/^\d{8}-\d{6}_/, ""));
346
+ /* skip */
310
347
  }
348
+ }
349
+
350
+ if (ctx) {
351
+ const label = rating ? `[${rating}/10]` : "";
352
+ failures.push(`${label} ${ctx}`.trim());
311
353
  } else {
312
354
  failures.push(dir.replace(/^\d{8}-\d{6}_/, ""));
313
355
  }
@@ -361,12 +403,28 @@ export function loadSynthesisRecommendations(): string {
361
403
 
362
404
  if (recs.length === 0) continue;
363
405
 
364
- // Extract metadata
365
- const periodMatch = content.match(/\*\*Period:\*\* (.+)/);
366
- const avgMatch = content.match(/\*\*Average Rating:\*\* (.+)/);
406
+ // Extract metadata — frontmatter or legacy
407
+ let period = "";
408
+ let avgRating = "";
409
+
410
+ if (hasFrontmatter(content)) {
411
+ const { meta } = parse<{
412
+ period?: string;
413
+ average_rating?: string;
414
+ }>(content);
415
+ period = meta.period || "";
416
+ avgRating = meta.average_rating ? `${meta.average_rating}/10` : "";
417
+ } else {
418
+ // DEPRECATED: legacy **Key:** format
419
+ const periodMatch = content.match(/\*\*Period:\*\* (.+)/);
420
+ const avgMatch = content.match(/\*\*Average Rating:\*\* (.+)/);
421
+ period = periodMatch?.[1] || "";
422
+ avgRating = avgMatch?.[1] || "";
423
+ }
424
+
367
425
  const header = [
368
426
  "## Pattern Synthesis",
369
- periodMatch ? `*${periodMatch[1]} — ${avgMatch?.[1] ?? ""}*` : "",
427
+ period ? `*${period} — ${avgRating}*` : "",
370
428
  ]
371
429
  .filter(Boolean)
372
430
  .join("\n");
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Lightweight YAML frontmatter parser/serializer.
3
+ *
4
+ * No external dependencies — parses simple key: value YAML between --- delimiters.
5
+ * Supports strings, numbers, booleans, and inline JSON arrays.
6
+ */
7
+
8
+ export interface Parsed<T = Record<string, string>> {
9
+ meta: T;
10
+ body: string;
11
+ }
12
+
13
+ const DELIMITER = /^---\s*$/m;
14
+
15
+ /**
16
+ * Parse frontmatter from a markdown string.
17
+ * Returns typed meta + body. If no frontmatter found, meta is empty and body is the full content.
18
+ */
19
+ export function parse<T = Record<string, string>>(content: string): Parsed<T> {
20
+ const parts = content.split(DELIMITER);
21
+
22
+ // Need at least 3 parts: before --- | frontmatter | after ---
23
+ // parts[0] should be empty (content starts with ---)
24
+ if (parts.length < 3 || parts[0].trim() !== "") {
25
+ return { meta: {} as T, body: content };
26
+ }
27
+
28
+ const rawMeta = parts[1];
29
+ const body = parts.slice(2).join("---").trim();
30
+
31
+ const meta: Record<string, unknown> = {};
32
+ for (const line of rawMeta.split("\n")) {
33
+ const match = line.match(/^(\w[\w-]*)\s*:\s*(.*)$/);
34
+ if (!match) continue;
35
+ const [, key, rawValue] = match;
36
+ const value = rawValue.trim();
37
+
38
+ // Inline JSON array
39
+ if (value.startsWith("[") && value.endsWith("]")) {
40
+ try {
41
+ meta[key] = JSON.parse(value);
42
+ continue;
43
+ } catch {
44
+ // Fall through to string handling
45
+ }
46
+ }
47
+
48
+ // Strip quotes
49
+ if (
50
+ (value.startsWith('"') && value.endsWith('"')) ||
51
+ (value.startsWith("'") && value.endsWith("'"))
52
+ ) {
53
+ meta[key] = value.slice(1, -1).replace(/\\"/g, '"');
54
+ continue;
55
+ }
56
+
57
+ // Type coercion
58
+ if (value === "true") meta[key] = true;
59
+ else if (value === "false") meta[key] = false;
60
+ else if (/^\d+$/.test(value)) meta[key] = Number.parseInt(value, 10);
61
+ else if (/^\d+\.\d+$/.test(value)) meta[key] = Number.parseFloat(value);
62
+ else meta[key] = value;
63
+ }
64
+
65
+ return { meta: meta as T, body };
66
+ }
67
+
68
+ /**
69
+ * Serialize metadata and body into a frontmatter string.
70
+ * Skips undefined/null values.
71
+ */
72
+ export function stringify(meta: Record<string, unknown>, body: string): string {
73
+ const lines: string[] = ["---"];
74
+
75
+ for (const [key, value] of Object.entries(meta)) {
76
+ if (value === undefined || value === null) continue;
77
+ if (Array.isArray(value)) {
78
+ lines.push(`${key}: ${JSON.stringify(value)}`);
79
+ } else if (typeof value === "string") {
80
+ lines.push(`${key}: "${value.replace(/"/g, '\\"')}"`);
81
+ } else {
82
+ lines.push(`${key}: ${String(value)}`);
83
+ }
84
+ }
85
+
86
+ lines.push("---");
87
+ return `${lines.join("\n")}\n\n${body.trim()}\n`;
88
+ }
89
+
90
+ /**
91
+ * Check if content has frontmatter (starts with ---).
92
+ */
93
+ export function hasFrontmatter(content: string): boolean {
94
+ return content.trimStart().startsWith("---");
95
+ }