diff --git a/Releases/v5.0.0/.claude/hooks/lib/learning-readback.ts b/Releases/v5.0.0/.claude/hooks/lib/learning-readback.ts index 4535e1844..7f86e0e55 100755 --- a/Releases/v5.0.0/.claude/hooks/lib/learning-readback.ts +++ b/Releases/v5.0.0/.claude/hooks/lib/learning-readback.ts @@ -26,12 +26,41 @@ import { readFileSync, existsSync, readdirSync } from 'fs'; import { join } from 'path'; +/** Default freshness window for the learning digest. Entries older than this are + * excluded so a quiet category can't surface weeks-old signals as if current. */ +const DEFAULT_MAX_AGE_DAYS = 21; + +/** + * Parse the timestamp encoded in a learning filename + * (YYYY-MM-DD-HHMMSS_LEARNING_*.md). Returns null if the name doesn't match. + */ +export function parseLearningDate(filename: string): Date | null { + const m = filename.match(/^(\d{4})-(\d{2})-(\d{2})-(\d{2})(\d{2})(\d{2})_/); + if (!m) return null; + const [, y, mo, d, h, mi, s] = m; + const dt = new Date(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(s)); + return Number.isNaN(dt.getTime()) ? null : dt; +} + +/** + * Whether a learning file is recent enough to surface in the digest. Unparseable + * names are treated as not fresh (excluded) — the digest must never present a + * signal it cannot date as though it were current. + */ +export function isFresh(filename: string, maxAgeDays: number, now: Date = new Date()): boolean { + const dt = parseLearningDate(filename); + if (!dt) return false; + const ageMs = now.getTime() - dt.getTime(); + return ageMs >= 0 && ageMs <= maxAgeDays * 86_400_000; +} + /** - * Read the N most recent learning files from a LEARNING subdirectory. - * Files are named YYYY-MM-DD-HHMMSS_LEARNING_*.md with YAML frontmatter. - * Extracts the **Feedback:** line and rating for compact display. + * Read the N most recent learning files from a LEARNING subdirectory, limited to + * a freshness window. Files are named YYYY-MM-DD-HHMMSS_LEARNING_*.md with YAML + * frontmatter. Extracts the **Feedback:** line and rating, and stamps each entry + * with its date so staleness is always visible. */ -function getRecentLearnings(baseDir: string, subdir: string, count: number): string[] { +function getRecentLearnings(baseDir: string, subdir: string, count: number, maxAgeDays: number = DEFAULT_MAX_AGE_DAYS): string[] { const insights: string[] = []; const learningDir = join(baseDir, 'MEMORY', 'LEARNING', subdir); if (!existsSync(learningDir)) return insights; @@ -56,6 +85,8 @@ function getRecentLearnings(baseDir: string, subdir: string, count: number): str for (const file of files) { if (insights.length >= count) break; + // Skip stale entries so a quiet category can't surface old signals. + if (!isFresh(file, maxAgeDays)) continue; try { const content = readFileSync(join(monthPath, file), 'utf-8'); const feedbackMatch = content.match(/\*\*Feedback:\*\*\s*(.+)/); @@ -63,7 +94,9 @@ function getRecentLearnings(baseDir: string, subdir: string, count: number): str if (feedbackMatch) { const rating = ratingMatch ? ratingMatch[1] : '?'; const feedback = feedbackMatch[1].substring(0, 80); - insights.push(`[${rating}/10] ${feedback}`); + const dt = parseLearningDate(file); + const dateStr = dt ? dt.toISOString().slice(0, 10) : ''; + insights.push(`[${rating}/10] (${dateStr}) ${feedback}`); } } catch { /* skip unreadable files */ } } diff --git a/Releases/v5.0.0/.claude/hooks/tests/learning-readback.test.ts b/Releases/v5.0.0/.claude/hooks/tests/learning-readback.test.ts new file mode 100644 index 000000000..fa62cdd41 --- /dev/null +++ b/Releases/v5.0.0/.claude/hooks/tests/learning-readback.test.ts @@ -0,0 +1,51 @@ +// Tests for the learning-digest staleness guard. +// +// loadLearningDigest() previously showed the N most-recent entries per category +// with no date and no age cutoff, so a quiet category surfaced weeks-old signals +// as if current. These tests cover the date helpers and the end-to-end behavior +// (fresh shown, stale hidden) against throwaway temp fixtures. +// +// Run: bun test learning-readback.test.ts +import { test, expect } from "bun:test"; +import { mkdirSync, writeFileSync, mkdtempSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; +import { parseLearningDate, isFresh, loadLearningDigest } from "../lib/learning-readback.ts"; + +test("parseLearningDate extracts the timestamp and rejects bad names", () => { + const d = parseLearningDate("2026-06-20-091530_LEARNING_sentiment-rating-7.md"); + expect(d).not.toBeNull(); + expect(d!.getFullYear()).toBe(2026); + expect(d!.getMonth()).toBe(5); // June (0-indexed) + expect(parseLearningDate("not-a-learning-file.md")).toBeNull(); +}); + +test("isFresh includes recent, excludes old and unparseable", () => { + const now = new Date(2026, 5, 20, 12, 0, 0); + expect(isFresh("2026-06-19-100000_LEARNING_x.md", 21, now)).toBe(true); + expect(isFresh("2026-04-01-100000_LEARNING_x.md", 21, now)).toBe(false); + expect(isFresh("garbled.md", 21, now)).toBe(false); +}); + +function pad(n: number): string { return String(n).padStart(2, "0"); } + +function writeLearning(base: string, subdir: string, d: Date, feedback: string, rating: number) { + const month = `${d.getFullYear()}-${pad(d.getMonth() + 1)}`; + const stamp = `${month}-${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`; + const dir = join(base, "MEMORY", "LEARNING", subdir, month); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, `${stamp}_LEARNING_test.md`), `---\nrating: ${rating}\n---\n**Feedback:** ${feedback}\n`); +} + +test("loadLearningDigest shows fresh entries and hides stale ones", () => { + const base = mkdtempSync(join(tmpdir(), "lrb-")); + const now = new Date(); + const stale = new Date(now.getTime() - 60 * 86_400_000); // 60 days ago + + writeLearning(base, "ALGORITHM", now, "fresh signal here", 7); + writeLearning(base, "ALGORITHM", stale, "stale signal here", 3); + + const out = loadLearningDigest(base) || ""; + expect(out).toContain("fresh signal here"); + expect(out).not.toContain("stale signal here"); +});