* refactor(shell-projection): remove deprecated wrappers + finalize ADRs (Phase 4, #3468) Final phase of the shell-command-projection expansion. Removes the legacy core.cjs wrappers (`atomicWriteFileSync`, `safeReadFile`, `normalizeMd`) now that every call site lives behind the seam, plus three Phase-3 stragglers (`graphify.cjs`, `template.cjs`, dead import in `profile-pipeline.cjs`). Documentation: - ADR-0009: addendum noting Phase 1–4 scope expansion (subprocess + file I/O ownership), supersession of "does not execute" constraint, and resolution of open Q4. - ADR-0010: status changed to Superseded by ADR-0009 with explanation. - CONTEXT.md "Shell Command Projection Module" entry already current from Phase 1 — no edit needed. Tests: - `tests/atomic-write.test.cjs` deleted — wrapper it tested is gone; `atomic-write-coverage.test.cjs` (Phase 3) covers platformWriteSync. - `tests/core.test.cjs::safeReadFile` + `::normalizeMd` describes deleted — wrappers are gone. - `tests/concurrency-safety.test.cjs` normalizeMd suite (behavioral / perf / snapshot) repointed via 2-line shim at the seam's `normalizeContent` — full regression coverage preserved. Test result: 9059/9041/18 — exact pre-Phase-4 baseline. All 18 failures are pre-existing path-with-spaces local-env issues. Closes #3468 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(shell-projection): migrate remaining raw fs.writeFileSync sites (Phase 4, #3468) Sweeps the 7 raw fs.writeFileSync call sites that bypassed the seam through Phase 3, folding them into platformWriteSync. Net -14 lines: deletes the local writeFileAtomicSync helper in installer-migrations.cjs and collapses surface.cjs's manual tmp+rename into a single seam call. Sites migrated: - drift.cjs (1) — frontmatter write - learnings.cjs (1) — learning record JSON write - install-profiles.cjs (1) — profile marker write (collapsed redundant mkdir) - gsd2-import.cjs (1) — imported file write (collapsed redundant mkdir) - surface.cjs (1) — surface state write (replaced manual tmp+rename block) - installer-migrations.cjs (3) — journal init/finalize + rewrite-json action; deleted private writeFileAtomicSync helper and its three call sites Two sites intentionally retained outside the seam: - planning-workspace.cjs:241 — workspace lock (wx-flag atomic-create; previously excluded by Phase 3) - installer-migrations.cjs:220 — install migration lock (fd write into wx-opened handle) - writeInstallState (installer-migrations.cjs) — strict atomic contract for install state; the seam's fallback-to-direct-write on rename failure would silently violate the invariant that install state must never be left half-written. Inline tmp+rename with rethrow keeps the original guarantee. Tests: 9059 / 9041 / 18 — exactly the pre-Phase-4 baseline; 18 failures are the pre-existing path-with-spaces local-env issues, identical files as before. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(installer-migrations): use strict atomic write for rollback install-state restore The rollback path was restoring INSTALL_STATE via platformWriteSync, which falls back to a direct write on rename failure and would silently violate the half-written invariant that the install-state contract guarantees elsewhere. Extracts the strict tmp+rename logic from writeInstallState into a shared atomicWriteInstallState(configDir, content) helper and routes both writeInstallState and rollbackAppliedMigrationResult through it. Preserves the existing null-handling (rmSync when previousInstallStateBytes === null) and existing failure-collection (failures.push on caught errors). Byte-faithful restore: previousInstallStateBytes is written as-is (no JSON parse round-trip), preserving the exact prior file contents on restore. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
380 lines
11 KiB
JavaScript
380 lines
11 KiB
JavaScript
/**
|
|
* 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');
|
|
const { platformWriteSync } = require('./shell-command-projection.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,
|
|
};
|
|
|
|
platformWriteSync(path.join(dir, `${id}.json`), JSON.stringify(record, null, 2));
|
|
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 <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 <duration>`
|
|
* @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 <id>`
|
|
* @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,
|
|
};
|