diff --git a/get-shit-done/bin/gsd-tools.cjs b/get-shit-done/bin/gsd-tools.cjs index 223d7c661..e68d436d9 100755 --- a/get-shit-done/bin/gsd-tools.cjs +++ b/get-shit-done/bin/gsd-tools.cjs @@ -147,6 +147,13 @@ * * Documentation: * docs-init Project context for docs-update workflow + * + * Learnings: + * learnings list List all global learnings (JSON) + * learnings query --tag Query learnings by tag + * learnings copy Copy from current project's LEARNINGS.md + * learnings prune --older-than Remove entries older than duration (e.g. 90d) + * learnings delete Delete a learning by ID */ const fs = require('fs'); @@ -167,6 +174,7 @@ const profilePipeline = require('./lib/profile-pipeline.cjs'); const profileOutput = require('./lib/profile-output.cjs'); const workstream = require('./lib/workstream.cjs'); const docs = require('./lib/docs.cjs'); +const learnings = require('./lib/learnings.cjs'); // ─── Arg parsing helpers ────────────────────────────────────────────────────── @@ -1003,6 +1011,34 @@ async function runCommand(command, args, cwd, raw) { break; } + // ─── Learnings ───────────────────────────────────────────────────────── + + case 'learnings': { + const subcommand = args[1]; + if (subcommand === 'list') { + learnings.cmdLearningsList(raw); + } else if (subcommand === 'query') { + const tagIdx = args.indexOf('--tag'); + const tag = tagIdx !== -1 ? args[tagIdx + 1] : null; + if (!tag) error('Usage: gsd-tools learnings query --tag '); + learnings.cmdLearningsQuery(tag, raw); + } else if (subcommand === 'copy') { + learnings.cmdLearningsCopy(cwd, raw); + } else if (subcommand === 'prune') { + const olderIdx = args.indexOf('--older-than'); + const olderThan = olderIdx !== -1 ? args[olderIdx + 1] : null; + if (!olderThan) error('Usage: gsd-tools learnings prune --older-than '); + learnings.cmdLearningsPrune(olderThan, raw); + } else if (subcommand === 'delete') { + const id = args[2]; + if (!id) error('Usage: gsd-tools learnings delete '); + learnings.cmdLearningsDelete(id, raw); + } else { + error('Unknown learnings subcommand. Available: list, query, copy, prune, delete'); + } + break; + } + default: error(`Unknown command: ${command}`); } diff --git a/get-shit-done/bin/lib/learnings.cjs b/get-shit-done/bin/lib/learnings.cjs new file mode 100644 index 000000000..c8f052c7b --- /dev/null +++ b/get-shit-done/bin/lib/learnings.cjs @@ -0,0 +1,378 @@ +/** + * Learnings — Global knowledge store with CRUD operations + * + * Provides a cross-project learnings store at ~/.gsd/knowledge/. + * Each learning is stored as an individual JSON file with content-hash + * deduplication. Supports write, read, list, query, delete, copy-from-project, + * and prune operations. + * + * Storage format: { id, source_project, date, context, learning, tags, content_hash } + * File naming: {id}.json + * Deduplication: SHA-256 of learning text + source_project + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const os = require('os'); +const { output, error: coreError } = require('./core.cjs'); + +// ─── Constants ─────────────────────────────────────────────────────────────── + +const DEFAULT_STORE_DIR = path.join(os.homedir(), '.gsd', 'knowledge'); + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** + * Get the store directory, allowing override for testing. + * @param {object} [opts] + * @param {string} [opts.storeDir] - Override store directory + * @returns {string} + */ +function getStoreDir(opts) { + return (opts && opts.storeDir) || DEFAULT_STORE_DIR; +} + +/** + * Ensure the store directory exists. Created on first write, not on install. + * @param {string} dir + */ +function ensureStoreDir(dir) { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } +} + +/** + * Generate a content hash for deduplication. + * Uses SHA-256 of learning text combined with source_project. + * @param {string} learning + * @param {string} sourceProject + * @returns {string} + */ +function contentHash(learning, sourceProject) { + return crypto.createHash('sha256') + .update(learning + '\n' + sourceProject) + .digest('hex'); +} + +/** + * Generate a unique ID based on timestamp + random suffix. + * @returns {string} + */ +function generateId() { + const ts = Date.now().toString(36); + const rand = crypto.randomBytes(4).toString('hex'); + return `${ts}-${rand}`; +} + +/** + * Read and parse a single learning JSON file. + * Returns null (with stderr warning) for malformed files. + * @param {string} filePath + * @returns {object|null} + */ +function readLearningFile(filePath) { + try { + const content = fs.readFileSync(filePath, 'utf-8'); + return JSON.parse(content); + } catch (err) { + process.stderr.write(`Warning: skipping malformed file ${filePath}: ${err.message}\n`); + return null; + } +} + +// ─── CRUD Operations ───────────────────────────────────────────────────────── + +/** + * Write a learning to the global store. + * Deduplicates by content hash — same content from same project is not stored twice. + * + * @param {object} entry + * @param {string} entry.source_project - Project name or path + * @param {string} entry.learning - The learning text + * @param {string} [entry.context] - Additional context + * @param {string[]} [entry.tags] - Tags for querying + * @param {object} [opts] + * @param {string} [opts.storeDir] - Override store directory + * @returns {{ id: string, created: boolean, content_hash: string }} + */ +function learningsWrite(entry, opts) { + const dir = getStoreDir(opts); + ensureStoreDir(dir); + + const hash = contentHash(entry.learning, entry.source_project); + + // Check for duplicate by scanning existing files + const files = fs.readdirSync(dir).filter(f => f.endsWith('.json')); + for (const file of files) { + const existing = readLearningFile(path.join(dir, file)); + if (existing && existing.content_hash === hash) { + return { id: existing.id, created: false, content_hash: hash }; + } + } + + const id = generateId(); + const record = { + id, + source_project: entry.source_project, + date: new Date().toISOString(), + context: entry.context || '', + learning: entry.learning, + tags: entry.tags || [], + content_hash: hash, + }; + + fs.writeFileSync(path.join(dir, `${id}.json`), JSON.stringify(record, null, 2), 'utf-8'); + return { id, created: true, content_hash: hash }; +} + +/** + * Read a single learning by ID. + * + * @param {string} id + * @param {object} [opts] + * @param {string} [opts.storeDir] - Override store directory + * @returns {object|null} + */ +function learningsRead(id, opts) { + if (!/^[a-z0-9]+-[a-f0-9]+$/.test(id)) return null; + const dir = getStoreDir(opts); + const filePath = path.join(dir, `${id}.json`); + if (!fs.existsSync(filePath)) return null; + return readLearningFile(filePath); +} + +/** + * List all learnings, sorted by date (newest first). + * + * @param {object} [opts] + * @param {string} [opts.storeDir] - Override store directory + * @returns {object[]} + */ +function learningsList(opts) { + const dir = getStoreDir(opts); + if (!fs.existsSync(dir)) return []; + + const files = fs.readdirSync(dir).filter(f => f.endsWith('.json')); + const results = []; + for (const file of files) { + const record = readLearningFile(path.join(dir, file)); + if (record) results.push(record); + } + + // Sort by date descending (newest first) + results.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); + return results; +} + +/** + * Query learnings by tag. + * + * @param {object} query + * @param {string} [query.tag] - Tag to filter by + * @param {object} [opts] + * @param {string} [opts.storeDir] - Override store directory + * @returns {object[]} + */ +function learningsQuery(query, opts) { + const all = learningsList(opts); + if (query && query.tag) { + return all.filter(r => r.tags && r.tags.includes(query.tag)); + } + return all; +} + +/** + * Delete a learning by ID. + * + * @param {string} id + * @param {object} [opts] + * @param {string} [opts.storeDir] - Override store directory + * @returns {boolean} true if deleted, false if not found + */ +function learningsDelete(id, opts) { + if (!/^[a-z0-9]+-[a-f0-9]+$/.test(id)) return false; + const dir = getStoreDir(opts); + const filePath = path.join(dir, `${id}.json`); + if (!fs.existsSync(filePath)) return false; + fs.unlinkSync(filePath); + return true; +} + +/** + * Copy learnings from a project's LEARNINGS.md into the global store. + * Parses markdown sections as individual learnings. Deduplicates by content hash. + * + * Expected LEARNINGS.md format: + * ## Section Title + * Learning content paragraph(s)... + * + * ## Another Section + * More content... + * + * @param {string} planningDir - Path to .planning/ directory (or directory containing LEARNINGS.md) + * @param {object} [opts] + * @param {string} [opts.storeDir] - Override store directory + * @param {string} [opts.sourceProject] - Project name (defaults to directory basename) + * @returns {{ total: number, created: number, skipped: number }} + */ +function learningsCopyFromProject(planningDir, opts) { + const learningsPath = path.join(planningDir, 'LEARNINGS.md'); + if (!fs.existsSync(learningsPath)) { + return { total: 0, created: 0, skipped: 0 }; + } + + const content = fs.readFileSync(learningsPath, 'utf-8'); + const sourceProject = (opts && opts.sourceProject) || path.basename(path.resolve(planningDir, '..')); + + // Parse markdown: split on ## headings + const sections = content.split(/^## /m).slice(1); // skip preamble before first ## + let created = 0; + let skipped = 0; + + for (const section of sections) { + const lines = section.trim().split('\n'); + const title = lines[0].trim(); + const body = lines.slice(1).join('\n').trim(); + if (!body) continue; + + // Extract tags from title (simple: use words as tags) + const tags = title.toLowerCase().split(/\s+/).filter(w => w.length > 2); + + const result = learningsWrite({ + source_project: sourceProject, + learning: body, + context: title, + tags, + }, opts); + + if (result.created) { + created++; + } else { + skipped++; + } + } + + return { total: created + skipped, created, skipped }; +} + +/** + * Prune learnings older than a given threshold. + * + * @param {string} olderThan - Duration string like "90d", "30d", "7d" + * @param {object} [opts] + * @param {string} [opts.storeDir] - Override store directory + * @returns {{ removed: number, kept: number }} + */ +function learningsPrune(olderThan, opts) { + const match = /^(\d+)d$/.exec(olderThan); + if (!match) { + throw new Error(`Invalid duration format: "${olderThan}" — expected format like "90d"`); + } + + const days = parseInt(match[1], 10); + const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000); + const dir = getStoreDir(opts); + + if (!fs.existsSync(dir)) return { removed: 0, kept: 0 }; + + const files = fs.readdirSync(dir).filter(f => f.endsWith('.json')); + let removed = 0; + let kept = 0; + + for (const file of files) { + const filePath = path.join(dir, file); + const record = readLearningFile(filePath); + if (!record) continue; + + const recordDate = new Date(record.date); + if (recordDate < cutoff) { + fs.unlinkSync(filePath); + removed++; + } else { + kept++; + } + } + + return { removed, kept }; +} + +// ─── CLI Command Handlers ──────────────────────────────────────────────────── + +/** + * Handle `gsd-tools learnings list` + * @param {boolean} raw - Raw output flag + */ +function cmdLearningsList(raw) { + const results = learningsList(); + output({ learnings: results, count: results.length }, raw); +} + +/** + * Handle `gsd-tools learnings query --tag ` + * @param {string} tag + * @param {boolean} raw - Raw output flag + */ +function cmdLearningsQuery(tag, raw) { + const results = learningsQuery({ tag }); + output({ learnings: results, count: results.length, tag }, raw); +} + +/** + * Handle `gsd-tools learnings copy` + * @param {string} cwd - Current working directory + * @param {boolean} raw - Raw output flag + */ +function cmdLearningsCopy(cwd, raw) { + const planningDir = path.join(cwd, '.planning'); + const result = learningsCopyFromProject(planningDir); + output(result, raw); +} + +/** + * Handle `gsd-tools learnings prune --older-than ` + * @param {string} olderThan - Duration string like "90d" + * @param {boolean} raw - Raw output flag + */ +function cmdLearningsPrune(olderThan, raw) { + try { + const result = learningsPrune(olderThan); + output(result, raw); + } catch (err) { + coreError(err.message); + } +} + +/** + * Handle `gsd-tools learnings delete ` + * @param {string} id + * @param {boolean} raw - Raw output flag + */ +function cmdLearningsDelete(id, raw) { + if (!/^[a-z0-9]+-[a-f0-9]+$/.test(id)) { + coreError(`Invalid learning ID: "${id}"`); + } + const deleted = learningsDelete(id); + output({ id, deleted }, raw); +} + +// ─── Exports ───────────────────────────────────────────────────────────────── + +module.exports = { + learningsWrite, + learningsRead, + learningsList, + learningsQuery, + learningsDelete, + learningsCopyFromProject, + learningsPrune, + cmdLearningsList, + cmdLearningsQuery, + cmdLearningsCopy, + cmdLearningsPrune, + cmdLearningsDelete, + DEFAULT_STORE_DIR, +}; diff --git a/tests/learnings.test.cjs b/tests/learnings.test.cjs new file mode 100644 index 000000000..2618d823c --- /dev/null +++ b/tests/learnings.test.cjs @@ -0,0 +1,526 @@ +/** + * Learnings Store Tests + * + * Tests for the global learnings CRUD library: write, read, list, query, + * delete, dedup, empty store, malformed file handling, copyFromProject, prune. + */ + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const { + learningsWrite, + learningsRead, + learningsList, + learningsQuery, + learningsDelete, + learningsCopyFromProject, + learningsPrune, +} = require('../get-shit-done/bin/lib/learnings.cjs'); +const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); + +// ─── Test Helpers ──────────────────────────────────────────────────────────── + +/** + * Create a unique temp directory for each test. + * @returns {string} + */ +function makeTempDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-learnings-test-')); +} + +/** + * Remove a directory recursively. + * @param {string} dir + */ +function cleanupDir(dir) { + if (fs.existsSync(dir)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +// ─── Write ─────────────────────────────────────────────────────────────────── + +describe('learningsWrite', () => { + let storeDir; + beforeEach(() => { storeDir = makeTempDir(); }); + afterEach(() => { cleanupDir(storeDir); }); + + test('creates a learning file with all required fields', () => { + const result = learningsWrite({ + source_project: 'test-project', + learning: 'Always validate inputs before processing', + context: 'security review', + tags: ['security', 'validation'], + }, { storeDir }); + + assert.ok(result.id, 'should return an id'); + assert.strictEqual(result.created, true); + assert.ok(result.content_hash, 'should return a content_hash'); + + // Verify file exists and has correct structure + const filePath = path.join(storeDir, `${result.id}.json`); + assert.ok(fs.existsSync(filePath), 'file should exist on disk'); + + const record = JSON.parse(fs.readFileSync(filePath, 'utf-8')); + assert.strictEqual(record.id, result.id); + assert.strictEqual(record.source_project, 'test-project'); + assert.strictEqual(record.learning, 'Always validate inputs before processing'); + assert.strictEqual(record.context, 'security review'); + assert.deepStrictEqual(record.tags, ['security', 'validation']); + assert.strictEqual(record.content_hash, result.content_hash); + assert.ok(record.date, 'should have a date'); + }); + + test('creates store directory on first write', () => { + const nestedDir = path.join(storeDir, 'nested', 'store'); + assert.ok(!fs.existsSync(nestedDir), 'dir should not exist yet'); + + learningsWrite({ + source_project: 'test', + learning: 'test learning', + }, { storeDir: nestedDir }); + + assert.ok(fs.existsSync(nestedDir), 'dir should be created on write'); + }); + + test('defaults context to empty string and tags to empty array', () => { + const result = learningsWrite({ + source_project: 'test', + learning: 'minimal entry', + }, { storeDir }); + + const record = learningsRead(result.id, { storeDir }); + assert.strictEqual(record.context, ''); + assert.deepStrictEqual(record.tags, []); + }); +}); + +// ─── Deduplication ─────────────────────────────────────────────────────────── + +describe('deduplication', () => { + let storeDir; + beforeEach(() => { storeDir = makeTempDir(); }); + afterEach(() => { cleanupDir(storeDir); }); + + test('same content from same project is not stored twice', () => { + const entry = { + source_project: 'my-project', + learning: 'Use content hashing for dedup', + tags: ['dedup'], + }; + + const first = learningsWrite(entry, { storeDir }); + const second = learningsWrite(entry, { storeDir }); + + assert.strictEqual(first.created, true); + assert.strictEqual(second.created, false); + assert.strictEqual(first.content_hash, second.content_hash); + assert.strictEqual(first.id, second.id); + + // Only one file on disk + const files = fs.readdirSync(storeDir).filter(f => f.endsWith('.json')); + assert.strictEqual(files.length, 1); + }); + + test('same learning from different projects creates separate entries', () => { + const learning = 'Same learning text'; + + const first = learningsWrite({ + source_project: 'project-a', + learning, + }, { storeDir }); + + const second = learningsWrite({ + source_project: 'project-b', + learning, + }, { storeDir }); + + assert.strictEqual(first.created, true); + assert.strictEqual(second.created, true); + assert.notStrictEqual(first.content_hash, second.content_hash); + + const files = fs.readdirSync(storeDir).filter(f => f.endsWith('.json')); + assert.strictEqual(files.length, 2); + }); +}); + +// ─── Read ──────────────────────────────────────────────────────────────────── + +describe('learningsRead', () => { + let storeDir; + beforeEach(() => { storeDir = makeTempDir(); }); + afterEach(() => { cleanupDir(storeDir); }); + + test('returns a learning by ID', () => { + const { id } = learningsWrite({ + source_project: 'test', + learning: 'readable entry', + tags: ['read'], + }, { storeDir }); + + const record = learningsRead(id, { storeDir }); + assert.ok(record); + assert.strictEqual(record.id, id); + assert.strictEqual(record.learning, 'readable entry'); + }); + + test('returns null for non-existent ID', () => { + const record = learningsRead('does-not-exist', { storeDir }); + assert.strictEqual(record, null); + }); +}); + +// ─── List ──────────────────────────────────────────────────────────────────── + +describe('learningsList', () => { + let storeDir; + beforeEach(() => { storeDir = makeTempDir(); }); + afterEach(() => { cleanupDir(storeDir); }); + + test('returns empty array for empty store', () => { + const results = learningsList({ storeDir }); + assert.deepStrictEqual(results, []); + }); + + test('returns empty array when store dir does not exist', () => { + const results = learningsList({ storeDir: path.join(storeDir, 'nonexistent') }); + assert.deepStrictEqual(results, []); + }); + + test('returns all learnings sorted by date (newest first)', () => { + // Write three entries with controlled dates + const id1 = learningsWrite({ + source_project: 'p1', + learning: 'first', + }, { storeDir }).id; + + // Manually adjust dates to control sort order + const file1 = path.join(storeDir, `${id1}.json`); + const rec1 = JSON.parse(fs.readFileSync(file1, 'utf-8')); + rec1.date = '2025-01-01T00:00:00.000Z'; + fs.writeFileSync(file1, JSON.stringify(rec1)); + + const id2 = learningsWrite({ + source_project: 'p2', + learning: 'second', + }, { storeDir }).id; + + const file2 = path.join(storeDir, `${id2}.json`); + const rec2 = JSON.parse(fs.readFileSync(file2, 'utf-8')); + rec2.date = '2025-06-15T00:00:00.000Z'; + fs.writeFileSync(file2, JSON.stringify(rec2)); + + const id3 = learningsWrite({ + source_project: 'p3', + learning: 'third', + }, { storeDir }).id; + + const file3 = path.join(storeDir, `${id3}.json`); + const rec3 = JSON.parse(fs.readFileSync(file3, 'utf-8')); + rec3.date = '2025-03-10T00:00:00.000Z'; + fs.writeFileSync(file3, JSON.stringify(rec3)); + + const results = learningsList({ storeDir }); + assert.strictEqual(results.length, 3); + assert.strictEqual(results[0].learning, 'second'); // newest + assert.strictEqual(results[1].learning, 'third'); // middle + assert.strictEqual(results[2].learning, 'first'); // oldest + }); +}); + +// ─── Query ─────────────────────────────────────────────────────────────────── + +describe('learningsQuery', () => { + let storeDir; + beforeEach(() => { storeDir = makeTempDir(); }); + afterEach(() => { cleanupDir(storeDir); }); + + test('filters by tag', () => { + learningsWrite({ + source_project: 'p1', + learning: 'auth lesson', + tags: ['auth', 'security'], + }, { storeDir }); + + learningsWrite({ + source_project: 'p2', + learning: 'ui lesson', + tags: ['ui', 'css'], + }, { storeDir }); + + learningsWrite({ + source_project: 'p3', + learning: 'auth pattern', + tags: ['auth', 'patterns'], + }, { storeDir }); + + const results = learningsQuery({ tag: 'auth' }, { storeDir }); + assert.strictEqual(results.length, 2); + assert.ok(results.every(r => r.tags.includes('auth'))); + }); + + test('returns all when no tag filter', () => { + learningsWrite({ source_project: 'p1', learning: 'a' }, { storeDir }); + learningsWrite({ source_project: 'p2', learning: 'b' }, { storeDir }); + + const results = learningsQuery({}, { storeDir }); + assert.strictEqual(results.length, 2); + }); + + test('returns empty array when tag not found', () => { + learningsWrite({ + source_project: 'p1', + learning: 'something', + tags: ['other'], + }, { storeDir }); + + const results = learningsQuery({ tag: 'nonexistent' }, { storeDir }); + assert.strictEqual(results.length, 0); + }); +}); + +// ─── Delete ────────────────────────────────────────────────────────────────── + +describe('learningsDelete', () => { + let storeDir; + beforeEach(() => { storeDir = makeTempDir(); }); + afterEach(() => { cleanupDir(storeDir); }); + + test('removes a learning by ID', () => { + const { id } = learningsWrite({ + source_project: 'test', + learning: 'to be deleted', + }, { storeDir }); + + assert.strictEqual(learningsDelete(id, { storeDir }), true); + assert.strictEqual(learningsRead(id, { storeDir }), null); + + const files = fs.readdirSync(storeDir).filter(f => f.endsWith('.json')); + assert.strictEqual(files.length, 0); + }); + + test('returns false for non-existent ID', () => { + assert.strictEqual(learningsDelete('nonexistent', { storeDir }), false); + }); +}); + +// ─── Malformed File Handling ───────────────────────────────────────────────── + +describe('malformed file handling', () => { + let storeDir; + beforeEach(() => { storeDir = makeTempDir(); }); + afterEach(() => { cleanupDir(storeDir); }); + + test('list skips malformed JSON files with warning', () => { + // Write a valid entry + learningsWrite({ + source_project: 'test', + learning: 'valid entry', + }, { storeDir }); + + // Write a malformed JSON file + fs.writeFileSync(path.join(storeDir, 'bad-entry.json'), '{not valid json!!!', 'utf-8'); + + const results = learningsList({ storeDir }); + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].learning, 'valid entry'); + }); + + test('write dedup check skips malformed files without crashing', () => { + // Write a malformed JSON file first + fs.writeFileSync(path.join(storeDir, 'corrupt.json'), 'corrupted!', 'utf-8'); + + // Writing should still succeed + const result = learningsWrite({ + source_project: 'test', + learning: 'new entry after corrupt', + }, { storeDir }); + + assert.strictEqual(result.created, true); + }); +}); + +// ─── Copy From Project ─────────────────────────────────────────────────────── + +describe('learningsCopyFromProject', () => { + let storeDir; + let projectDir; + + beforeEach(() => { + storeDir = makeTempDir(); + projectDir = makeTempDir(); + }); + afterEach(() => { + cleanupDir(storeDir); + cleanupDir(projectDir); + }); + + test('copies learnings from LEARNINGS.md into global store', () => { + const learningsMd = `# Project Learnings + +## Authentication Patterns +Always use OAuth2 for third-party auth. +Never store tokens in localStorage. + +## Database Design +Normalize to 3NF unless read performance demands denormalization. + +## Error Handling +Use custom error classes that extend Error. +`; + fs.writeFileSync(path.join(projectDir, 'LEARNINGS.md'), learningsMd, 'utf-8'); + + const result = learningsCopyFromProject(projectDir, { + storeDir, + sourceProject: 'my-app', + }); + + assert.strictEqual(result.created, 3); + assert.strictEqual(result.skipped, 0); + + const all = learningsList({ storeDir }); + assert.strictEqual(all.length, 3); + + // Verify content was captured + const learningTexts = all.map(r => r.learning); + assert.ok(learningTexts.some(t => t.includes('OAuth2'))); + assert.ok(learningTexts.some(t => t.includes('Normalize to 3NF'))); + assert.ok(learningTexts.some(t => t.includes('custom error classes'))); + }); + + test('deduplicates on second copy', () => { + const learningsMd = `# Learnings + +## Testing +Always write tests first. +`; + fs.writeFileSync(path.join(projectDir, 'LEARNINGS.md'), learningsMd, 'utf-8'); + + learningsCopyFromProject(projectDir, { storeDir, sourceProject: 'app' }); + const second = learningsCopyFromProject(projectDir, { storeDir, sourceProject: 'app' }); + + assert.strictEqual(second.created, 0); + assert.strictEqual(second.skipped, 1); + + const all = learningsList({ storeDir }); + assert.strictEqual(all.length, 1); + }); + + test('returns zero counts when LEARNINGS.md does not exist', () => { + const result = learningsCopyFromProject(projectDir, { storeDir }); + assert.deepStrictEqual(result, { total: 0, created: 0, skipped: 0 }); + }); + + test('skips sections with empty body', () => { + const learningsMd = `# Learnings + +## Empty Section + +## Has Content +Real content here. +`; + fs.writeFileSync(path.join(projectDir, 'LEARNINGS.md'), learningsMd, 'utf-8'); + + const result = learningsCopyFromProject(projectDir, { storeDir, sourceProject: 'app' }); + assert.strictEqual(result.created, 1); + + const all = learningsList({ storeDir }); + assert.strictEqual(all.length, 1); + assert.ok(all[0].learning.includes('Real content')); + }); +}); + +// ─── Prune ─────────────────────────────────────────────────────────────────── + +describe('learningsPrune', () => { + let storeDir; + beforeEach(() => { storeDir = makeTempDir(); }); + afterEach(() => { cleanupDir(storeDir); }); + + test('removes entries older than threshold', () => { + // Create an old entry + const oldId = learningsWrite({ + source_project: 'old-project', + learning: 'ancient wisdom', + }, { storeDir }).id; + + // Backdate it to 100 days ago + const oldFile = path.join(storeDir, `${oldId}.json`); + const oldRec = JSON.parse(fs.readFileSync(oldFile, 'utf-8')); + oldRec.date = new Date(Date.now() - 100 * 24 * 60 * 60 * 1000).toISOString(); + fs.writeFileSync(oldFile, JSON.stringify(oldRec)); + + // Create a recent entry + learningsWrite({ + source_project: 'new-project', + learning: 'fresh knowledge', + }, { storeDir }); + + const result = learningsPrune('90d', { storeDir }); + assert.strictEqual(result.removed, 1); + assert.strictEqual(result.kept, 1); + + const remaining = learningsList({ storeDir }); + assert.strictEqual(remaining.length, 1); + assert.strictEqual(remaining[0].learning, 'fresh knowledge'); + }); + + test('keeps all entries when none are old enough', () => { + learningsWrite({ source_project: 'p', learning: 'recent' }, { storeDir }); + + const result = learningsPrune('30d', { storeDir }); + assert.strictEqual(result.removed, 0); + assert.strictEqual(result.kept, 1); + }); + + test('returns zeros when store does not exist', () => { + const result = learningsPrune('90d', { storeDir: path.join(storeDir, 'nope') }); + assert.deepStrictEqual(result, { removed: 0, kept: 0 }); + }); + + test('throws on invalid duration format', () => { + assert.throws( + () => learningsPrune('invalid', { storeDir }), + /Invalid duration format/ + ); + }); +}); + +// ─── CLI Integration ──────────────────────────────────────────────────────── + +describe('CLI integration', () => { + let tmpDir; + beforeEach(() => { tmpDir = createTempProject(); }); + afterEach(() => { cleanup(tmpDir); }); + + test('learnings list returns valid JSON', () => { + const res = runGsdTools(['learnings', 'list'], tmpDir, { HOME: tmpDir }); + assert.strictEqual(res.success, true); + const parsed = JSON.parse(res.output); + assert.ok(Array.isArray(parsed.learnings)); + assert.strictEqual(typeof parsed.count, 'number'); + }); + + test('learnings query --tag succeeds', () => { + const res = runGsdTools(['learnings', 'query', '--tag', 'auth'], tmpDir, { HOME: tmpDir }); + assert.strictEqual(res.success, true); + const parsed = JSON.parse(res.output); + assert.ok(Array.isArray(parsed.learnings)); + }); + + test('learnings prune with bad format exits non-zero', () => { + const res = runGsdTools(['learnings', 'prune', '--older-than', 'badformat'], tmpDir, { HOME: tmpDir }); + assert.strictEqual(res.success, false); + assert.ok(res.error.includes('Invalid duration format')); + }); + + test('learnings unknown subcommand exits non-zero', () => { + const res = runGsdTools(['learnings', 'unknown'], tmpDir, { HOME: tmpDir }); + assert.strictEqual(res.success, false); + assert.ok(res.error.includes('Unknown learnings subcommand')); + }); +});