aios-core
Version:
Synkra AIOS: AI-Orchestrated System for Full Stack Development - Core Framework
1,296 lines (1,118 loc) • 38.5 kB
JavaScript
/**
* AIOS Gotchas Documenter
*
* Story: 7.4 - Gotchas Documenter
* Epic: Epic 7 - Memory Layer
*
* Automatically extracts gotchas from session insights and consolidates
* them into a searchable, categorized knowledge base.
*
* Features:
* - AC1: Extracts gotchas from session insights automatically
* - AC2: Generates `.aios/gotchas.md` consolidated
* - AC3: Format: Wrong, Right, Reason for each gotcha
* - AC4: Categorized by area (API, Frontend, Database, etc.)
* - AC5: Referenced by Self-Critique (Epic 4)
* - AC6: Updated automatically after session insights
* - AC7: Command `*list-gotchas` for quick lookup
*
* @author @dev (Dex)
* @version 1.0.0
*/
const fs = require('fs');
const path = require('path');
// ═══════════════════════════════════════════════════════════════════════════════════
// CONFIGURATION
// ═══════════════════════════════════════════════════════════════════════════════════
const CONFIG = {
// Output paths
outputPath: '.aios/gotchas.md',
outputJsonPath: '.aios/gotchas.json',
// Input paths for scanning
insightsPaths: [
'docs/stories/**/insights/*.json',
'docs/stories/**/session-*.json',
'.aios/insights/*.json',
],
// Version
version: '1.0.0',
// Schema version
schemaVersion: 'aios-gotchas-v1',
};
// ═══════════════════════════════════════════════════════════════════════════════════
// ENUMS
// ═══════════════════════════════════════════════════════════════════════════════════
/**
* Gotcha categories (AC4)
*/
const Category = {
STATE_MANAGEMENT: 'State Management',
API: 'API',
DATABASE: 'Database',
FRONTEND: 'Frontend/React',
TESTING: 'Testing',
BUILD_DEPLOY: 'Build/Deploy',
TYPESCRIPT: 'TypeScript',
AUTHENTICATION: 'Authentication',
PERFORMANCE: 'Performance',
SECURITY: 'Security',
OTHER: 'Other',
};
/**
* Severity levels
*/
const Severity = {
HIGH: 'high',
MEDIUM: 'medium',
LOW: 'low',
};
/**
* Category keywords for auto-detection
*/
const CATEGORY_KEYWORDS = {
[Category.STATE_MANAGEMENT]: [
'zustand',
'redux',
'state',
'store',
'persist',
'hydration',
'context',
'recoil',
'jotai',
'mobx',
],
[Category.API]: [
'fetch',
'axios',
'http',
'endpoint',
'rest',
'graphql',
'api',
'request',
'response',
'cors',
],
[Category.DATABASE]: [
'sql',
'postgres',
'mysql',
'mongodb',
'prisma',
'drizzle',
'supabase',
'query',
'migration',
'orm',
],
[Category.FRONTEND]: [
'react',
'component',
'hook',
'useEffect',
'useState',
'render',
'jsx',
'tsx',
'dom',
'css',
'tailwind',
'nextjs',
'next.js',
],
[Category.TESTING]: [
'test',
'jest',
'vitest',
'mock',
'stub',
'expect',
'assert',
'coverage',
'e2e',
'playwright',
],
[Category.BUILD_DEPLOY]: [
'build',
'deploy',
'ci',
'cd',
'webpack',
'vite',
'docker',
'vercel',
'railway',
'bundle',
],
[Category.TYPESCRIPT]: [
'typescript',
'type',
'interface',
'generic',
'infer',
'as const',
'satisfies',
'enum',
'tsconfig',
],
[Category.AUTHENTICATION]: [
'auth',
'login',
'logout',
'session',
'token',
'jwt',
'oauth',
'password',
'credential',
],
[Category.PERFORMANCE]: [
'performance',
'memory',
'leak',
'optimization',
'cache',
'lazy',
'debounce',
'throttle',
'memoize',
],
[Category.SECURITY]: [
'security',
'xss',
'csrf',
'injection',
'sanitize',
'escape',
'vulnerability',
'encrypt',
],
};
// ═══════════════════════════════════════════════════════════════════════════════════
// GOTCHAS DOCUMENTER CLASS
// ═══════════════════════════════════════════════════════════════════════════════════
class GotchasDocumenter {
/**
* Create a new GotchasDocumenter instance
*
* @param {string} rootPath - Project root path
* @param {Object} [options] - Configuration options
* @param {string} [options.outputPath] - Custom output path for gotchas.md
* @param {boolean} [options.quiet] - Suppress console output
*/
constructor(rootPath, options = {}) {
this.rootPath = rootPath || process.cwd();
this.outputPath = options.outputPath || CONFIG.outputPath;
this.quiet = options.quiet || false;
// Initialize gotchas storage
this.gotchas = new Map(); // id -> gotcha
this.byCategory = new Map(); // category -> gotcha[]
this.byStory = new Map(); // storyId -> gotcha[]
// Statistics
this.stats = {
insightsScanned: 0,
gotchasExtracted: 0,
gotchasDeduplicated: 0,
categoriesFound: 0,
};
}
// ═══════════════════════════════════════════════════════════════════════════════
// COLLECTION METHODS
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scan all insights files for gotchas (AC1)
*
* @returns {Promise<number>} Number of insights files scanned
*/
async scanInsightsFiles() {
const insightsFiles = [];
// Find all insights files using glob patterns
for (const pattern of CONFIG.insightsPaths) {
const files = this._findFiles(pattern);
insightsFiles.push(...files);
}
// Remove duplicates
const uniqueFiles = [...new Set(insightsFiles)];
this._log(`Found ${uniqueFiles.length} insights files to scan`);
// Process each file
for (const filePath of uniqueFiles) {
try {
const gotchas = await this.extractGotchas(filePath);
this.stats.insightsScanned++;
if (gotchas.length > 0) {
this._log(` Extracted ${gotchas.length} gotchas from ${path.basename(filePath)}`);
}
} catch (error) {
this._log(` Warning: Failed to process ${filePath}: ${error.message}`, 'warn');
}
}
return this.stats.insightsScanned;
}
/**
* Extract gotchas from a single insights file (AC1)
*
* @param {string} insightsFile - Path to insights JSON file
* @returns {Object[]} Array of extracted gotchas
*/
async extractGotchas(insightsFile) {
const absolutePath = path.isAbsolute(insightsFile)
? insightsFile
: path.join(this.rootPath, insightsFile);
if (!fs.existsSync(absolutePath)) {
throw new Error(`Insights file not found: ${absolutePath}`);
}
const content = fs.readFileSync(absolutePath, 'utf-8');
const insights = JSON.parse(content);
const extractedGotchas = [];
// Extract from gotchasFound array (standard schema)
if (insights.gotchasFound && Array.isArray(insights.gotchasFound)) {
for (const gotcha of insights.gotchasFound) {
const normalized = this._normalizeGotcha(gotcha, insights);
if (normalized) {
extractedGotchas.push(normalized);
this._addGotcha(normalized);
}
}
}
// Extract from discoveries that look like gotchas
if (insights.discoveries && Array.isArray(insights.discoveries)) {
for (const discovery of insights.discoveries) {
if (this._isGotchaLike(discovery)) {
const gotcha = this._discoveryToGotcha(discovery, insights);
if (gotcha) {
extractedGotchas.push(gotcha);
this._addGotcha(gotcha);
}
}
}
}
// Extract from patternsLearned that include gotcha aspects
if (insights.patternsLearned && Array.isArray(insights.patternsLearned)) {
for (const pattern of insights.patternsLearned) {
if (pattern.antiPattern || pattern.wrong) {
const gotcha = this._patternToGotcha(pattern, insights);
if (gotcha) {
extractedGotchas.push(gotcha);
this._addGotcha(gotcha);
}
}
}
}
this.stats.gotchasExtracted += extractedGotchas.length;
return extractedGotchas;
}
/**
* Deduplicate gotchas based on content similarity
*
* @returns {number} Number of duplicates removed
*/
deduplicateGotchas() {
const initialCount = this.gotchas.size;
const seen = new Map(); // hash -> gotchaId
for (const [id, gotcha] of this.gotchas) {
const hash = this._generateGotchaHash(gotcha);
if (seen.has(hash)) {
// Merge with existing, keeping the more recent one
const existingId = seen.get(hash);
const existing = this.gotchas.get(existingId);
if (new Date(gotcha.discoveredAt) > new Date(existing.discoveredAt)) {
// Replace with newer
this.gotchas.delete(existingId);
this.gotchas.set(id, gotcha);
seen.set(hash, id);
} else {
// Remove duplicate
this.gotchas.delete(id);
}
} else {
seen.set(hash, id);
}
}
const removed = initialCount - this.gotchas.size;
this.stats.gotchasDeduplicated = removed;
// Rebuild category index
this._rebuildCategoryIndex();
return removed;
}
/**
* Categorize gotchas by area (AC4)
*
* @returns {Map<string, Object[]>} Gotchas by category
*/
categorizeGotchas() {
// Clear and rebuild category index
this._rebuildCategoryIndex();
this.stats.categoriesFound = this.byCategory.size;
return this.byCategory;
}
// ═══════════════════════════════════════════════════════════════════════════════
// OUTPUT METHODS
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Generate markdown output (AC2, AC3)
*
* @returns {string} Markdown content for gotchas.md
*/
generateMarkdown() {
const categories = this.categorizeGotchas();
const now = new Date().toISOString();
let md = `# Known Gotchas
> Auto-generated from session insights
> Last updated: ${now}
> Total gotchas: ${this.gotchas.size}
This document contains common pitfalls and their solutions discovered during development.
Each gotcha includes the **wrong** approach, the **right** approach, and the **reason** why.
---
## Table of Contents
`;
// Generate TOC
for (const category of Object.values(Category)) {
if (categories.has(category)) {
const count = categories.get(category).length;
const anchor = this._toAnchor(category);
md += `- [${category}](#${anchor}) (${count})\n`;
}
}
md += '\n---\n\n';
// Generate content for each category
for (const category of Object.values(Category)) {
if (!categories.has(category)) continue;
const gotchasList = categories.get(category);
if (gotchasList.length === 0) continue;
md += `## ${category}\n\n`;
// Sort by severity (high first), then by date (recent first)
const sorted = [...gotchasList].sort((a, b) => {
const severityOrder = { high: 0, medium: 1, low: 2 };
const severityDiff = (severityOrder[a.severity] || 2) - (severityOrder[b.severity] || 2);
if (severityDiff !== 0) return severityDiff;
return new Date(b.discoveredAt) - new Date(a.discoveredAt);
});
for (const gotcha of sorted) {
md += this._renderGotcha(gotcha);
}
}
// Add statistics section
md += `---
## Statistics
| Metric | Value |
|--------|-------|
| Total Gotchas | ${this.gotchas.size} |
| Categories | ${this.stats.categoriesFound} |
| Insights Scanned | ${this.stats.insightsScanned} |
| Duplicates Merged | ${this.stats.gotchasDeduplicated} |
---
*Generated by AIOS Gotchas Documenter v${CONFIG.version}*
`;
return md;
}
/**
* Save gotchas to file (AC2)
*
* @param {string} [outputPath] - Custom output path
* @returns {string} Path to saved file
*/
saveGotchas(outputPath) {
const markdown = this.generateMarkdown();
const savePath = outputPath
? path.isAbsolute(outputPath)
? outputPath
: path.join(this.rootPath, outputPath)
: path.join(this.rootPath, this.outputPath);
// Ensure directory exists
const dir = path.dirname(savePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(savePath, markdown, 'utf-8');
this._log(`Saved gotchas to: ${savePath}`);
// Also save JSON version
const jsonPath = savePath.replace('.md', '.json');
fs.writeFileSync(jsonPath, JSON.stringify(this.toJSON(), null, 2), 'utf-8');
this._log(`Saved JSON to: ${jsonPath}`);
return savePath;
}
/**
* Merge with existing gotchas file
*
* @param {string} existingPath - Path to existing gotchas.json
* @returns {number} Number of new gotchas added
*/
mergeWithExisting(existingPath) {
const absolutePath = path.isAbsolute(existingPath)
? existingPath
: path.join(this.rootPath, existingPath);
if (!fs.existsSync(absolutePath)) {
this._log('No existing gotchas file found, creating new');
return this.gotchas.size;
}
try {
const content = fs.readFileSync(absolutePath, 'utf-8');
const existing = JSON.parse(content);
// Add existing gotchas
const initialCount = this.gotchas.size;
if (existing.gotchas && Array.isArray(existing.gotchas)) {
for (const gotcha of existing.gotchas) {
this._addGotcha(gotcha);
}
}
// Deduplicate after merge
this.deduplicateGotchas();
const newCount = this.gotchas.size - initialCount;
this._log(`Merged with existing: ${newCount} new gotchas added`);
return newCount;
} catch (error) {
this._log(`Warning: Failed to merge with existing: ${error.message}`, 'warn');
return 0;
}
}
/**
* Convert to JSON schema (AC6)
*
* @returns {Object} JSON schema for gotchas
*/
toJSON() {
const categories = this.categorizeGotchas();
return {
schema: CONFIG.schemaVersion,
version: CONFIG.version,
generatedAt: new Date().toISOString(),
statistics: {
total: this.gotchas.size,
bySeverity: {
high: [...this.gotchas.values()].filter((g) => g.severity === Severity.HIGH).length,
medium: [...this.gotchas.values()].filter((g) => g.severity === Severity.MEDIUM).length,
low: [...this.gotchas.values()].filter((g) => g.severity === Severity.LOW).length,
},
byCategory: Object.fromEntries(
[...categories.entries()].map(([cat, items]) => [cat, items.length])
),
insightsScanned: this.stats.insightsScanned,
},
gotchas: [...this.gotchas.values()],
categories: Object.fromEntries(categories),
};
}
// ═══════════════════════════════════════════════════════════════════════════════
// QUERY METHODS (AC7)
// ═══════════════════════════════════════════════════════════════════════════════
/**
* List gotchas by category
*
* @param {string} category - Category name
* @returns {Object[]} Gotchas in category
*/
listByCategory(category) {
const normalizedCategory = this._normalizeCategory(category);
return this.byCategory.get(normalizedCategory) || [];
}
/**
* List gotchas by severity
*
* @param {string} severity - Severity level (high, medium, low)
* @returns {Object[]} Gotchas with severity
*/
listBySeverity(severity) {
const normalizedSeverity = severity.toLowerCase();
return [...this.gotchas.values()].filter((g) => g.severity === normalizedSeverity);
}
/**
* Search gotchas by query string
*
* @param {string} query - Search query
* @returns {Object[]} Matching gotchas
*/
search(query) {
const lowerQuery = query.toLowerCase();
return [...this.gotchas.values()].filter((gotcha) => {
const searchText = [
gotcha.title || '',
gotcha.wrong || '',
gotcha.right || '',
gotcha.reason || '',
gotcha.category || '',
]
.join(' ')
.toLowerCase();
return searchText.includes(lowerQuery);
});
}
/**
* Get all gotchas
*
* @returns {Object[]} All gotchas
*/
getAll() {
return [...this.gotchas.values()];
}
// ═══════════════════════════════════════════════════════════════════════════════
// PRIVATE HELPER METHODS
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Find files matching glob pattern (simplified implementation)
* @private
*/
_findFiles(pattern) {
const files = [];
// Convert glob to regex-ish pattern
const parts = pattern.split('/');
const basePath = this.rootPath;
const search = (currentPath, patternParts) => {
if (patternParts.length === 0) return;
const currentPattern = patternParts[0];
const remaining = patternParts.slice(1);
if (currentPattern === '**') {
// Recursive search
if (fs.existsSync(currentPath) && fs.statSync(currentPath).isDirectory()) {
const entries = fs.readdirSync(currentPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(currentPath, entry.name);
if (entry.isDirectory()) {
// Continue recursive search
search(fullPath, patternParts);
// Also try next pattern at this level
search(fullPath, remaining);
} else if (entry.isFile() && remaining.length === 0) {
files.push(fullPath);
}
}
}
} else if (currentPattern.includes('*')) {
// Wildcard pattern
const regex = new RegExp('^' + currentPattern.replace(/\*/g, '.*') + '$');
if (fs.existsSync(currentPath) && fs.statSync(currentPath).isDirectory()) {
const entries = fs.readdirSync(currentPath, { withFileTypes: true });
for (const entry of entries) {
if (regex.test(entry.name)) {
const fullPath = path.join(currentPath, entry.name);
if (remaining.length === 0) {
if (entry.isFile()) {
files.push(fullPath);
}
} else if (entry.isDirectory()) {
search(fullPath, remaining);
}
}
}
}
} else {
// Exact match
const fullPath = path.join(currentPath, currentPattern);
if (fs.existsSync(fullPath)) {
if (remaining.length === 0) {
if (fs.statSync(fullPath).isFile()) {
files.push(fullPath);
}
} else if (fs.statSync(fullPath).isDirectory()) {
search(fullPath, remaining);
}
}
}
};
search(basePath, parts);
return files;
}
/**
* Normalize gotcha from raw insight data
* @private
*/
_normalizeGotcha(raw, insights) {
if (!raw || (!raw.wrong && !raw.right && !raw.reason && !raw.description)) {
return null;
}
const id = this._generateGotchaId(raw, insights);
const category = raw.category
? this._normalizeCategory(raw.category)
: this._detectCategory(raw);
return {
id,
title: raw.title || this._generateTitle(raw),
category,
wrong: raw.wrong || raw.antiPattern || '',
right: raw.right || raw.correctPattern || raw.pattern || '',
reason: raw.reason || raw.explanation || raw.description || '',
severity: this._normalizeSeverity(raw.severity),
discoveredAt: raw.discoveredAt || insights.capturedAt || new Date().toISOString(),
storyId: insights.storyId || null,
relatedFiles: raw.relatedFiles || [],
tags: raw.tags || [],
};
}
/**
* Check if a discovery looks like a gotcha
* @private
*/
_isGotchaLike(discovery) {
const text = `${discovery.description || ''} ${discovery.content || ''}`.toLowerCase();
return (
text.includes('pitfall') ||
text.includes('gotcha') ||
text.includes('wrong') ||
text.includes('avoid') ||
text.includes('don\'t') ||
text.includes('should not') ||
text.includes('instead of') ||
text.includes('common mistake')
);
}
/**
* Convert a discovery to a gotcha
* @private
*/
_discoveryToGotcha(discovery, insights) {
return this._normalizeGotcha(
{
description: discovery.description,
category: discovery.category,
relatedFiles: discovery.relatedFiles,
severity: discovery.relevance === 'high' ? Severity.HIGH : Severity.MEDIUM,
},
insights
);
}
/**
* Convert a pattern to a gotcha
* @private
*/
_patternToGotcha(pattern, insights) {
return this._normalizeGotcha(
{
title: pattern.name,
wrong: pattern.antiPattern || pattern.wrong || '',
right: pattern.example || pattern.pattern || pattern.right || '',
reason: pattern.description || pattern.reason || '',
category: this._detectCategory(pattern),
severity: Severity.MEDIUM,
},
insights
);
}
/**
* Add gotcha to storage
* @private
*/
_addGotcha(gotcha) {
if (!gotcha || !gotcha.id) return;
this.gotchas.set(gotcha.id, gotcha);
// Index by category
if (!this.byCategory.has(gotcha.category)) {
this.byCategory.set(gotcha.category, []);
}
this.byCategory.get(gotcha.category).push(gotcha);
// Index by story
if (gotcha.storyId) {
if (!this.byStory.has(gotcha.storyId)) {
this.byStory.set(gotcha.storyId, []);
}
this.byStory.get(gotcha.storyId).push(gotcha);
}
}
/**
* Rebuild category index
* @private
*/
_rebuildCategoryIndex() {
this.byCategory.clear();
for (const gotcha of this.gotchas.values()) {
const category = gotcha.category || Category.OTHER;
if (!this.byCategory.has(category)) {
this.byCategory.set(category, []);
}
this.byCategory.get(category).push(gotcha);
}
}
/**
* Generate unique gotcha ID
* @private
*/
_generateGotchaId(gotcha, insights) {
const content = `${gotcha.wrong || ''}${gotcha.right || ''}${gotcha.reason || ''}`;
const hash = this._simpleHash(content);
const storyPart = insights.storyId ? `-${insights.storyId}` : '';
return `gotcha${storyPart}-${hash}`;
}
/**
* Generate hash for deduplication
* @private
*/
_generateGotchaHash(gotcha) {
const content = `${gotcha.wrong || ''}${gotcha.right || ''}`.toLowerCase().replace(/\s+/g, '');
return this._simpleHash(content);
}
/**
* Simple hash function
* @private
*/
_simpleHash(str) {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash; // Convert to 32bit integer
}
return Math.abs(hash).toString(16).substring(0, 8);
}
/**
* Detect category from gotcha content
* @private
*/
_detectCategory(gotcha) {
const text = `${gotcha.wrong || ''} ${gotcha.right || ''} ${gotcha.reason || ''} ${gotcha.description || ''} ${gotcha.title || ''}`.toLowerCase();
for (const [category, keywords] of Object.entries(CATEGORY_KEYWORDS)) {
for (const keyword of keywords) {
if (text.includes(keyword.toLowerCase())) {
return category;
}
}
}
return Category.OTHER;
}
/**
* Normalize category name
* @private
*/
_normalizeCategory(category) {
if (!category) return Category.OTHER;
const lower = category.toLowerCase();
// Direct match
for (const cat of Object.values(Category)) {
if (cat.toLowerCase() === lower) {
return cat;
}
}
// Keyword-based match
for (const [cat, keywords] of Object.entries(CATEGORY_KEYWORDS)) {
for (const keyword of keywords) {
if (lower.includes(keyword.toLowerCase())) {
return cat;
}
}
}
return Category.OTHER;
}
/**
* Normalize severity level
* @private
*/
_normalizeSeverity(severity) {
if (!severity) return Severity.MEDIUM;
const lower = severity.toLowerCase();
if (lower === 'high' || lower === 'critical') return Severity.HIGH;
if (lower === 'low' || lower === 'minor') return Severity.LOW;
return Severity.MEDIUM;
}
/**
* Generate title from gotcha content
* @private
*/
_generateTitle(gotcha) {
const reason = gotcha.reason || gotcha.description || '';
if (reason.length > 0) {
// Take first sentence or first 60 chars
const firstSentence = reason.split(/[.!?]/)[0];
return firstSentence.length > 60 ? firstSentence.substring(0, 57) + '...' : firstSentence;
}
return 'Untitled Gotcha';
}
/**
* Convert string to anchor link
* @private
*/
_toAnchor(text) {
return text
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
}
/**
* Render a single gotcha as markdown (AC3)
* @private
*/
_renderGotcha(gotcha) {
const severityEmoji =
{
high: '**[HIGH]**',
medium: '**[MEDIUM]**',
low: '[LOW]',
}[gotcha.severity] || '';
let md = `### ${gotcha.title}\n\n`;
md += `${severityEmoji}\n\n`;
if (gotcha.wrong) {
md += `**Wrong:**\n\`\`\`typescript\n${gotcha.wrong}\n\`\`\`\n\n`;
}
if (gotcha.right) {
md += `**Right:**\n\`\`\`typescript\n${gotcha.right}\n\`\`\`\n\n`;
}
if (gotcha.reason) {
md += `**Reason:** ${gotcha.reason}\n\n`;
}
md += `**Severity:** ${gotcha.severity.charAt(0).toUpperCase() + gotcha.severity.slice(1)}\n\n`;
if (gotcha.storyId) {
const date = gotcha.discoveredAt
? new Date(gotcha.discoveredAt).toISOString().split('T')[0]
: 'Unknown';
md += `**Discovered:** ${gotcha.storyId} (${date})\n\n`;
}
if (gotcha.relatedFiles && gotcha.relatedFiles.length > 0) {
md += `**Related Files:** ${gotcha.relatedFiles.join(', ')}\n\n`;
}
md += '---\n\n';
return md;
}
/**
* Log message
* @private
*/
_log(message, level = 'info') {
if (this.quiet) return;
const prefix = {
info: '',
warn: '\x1b[33m[WARN]\x1b[0m ',
error: '\x1b[31m[ERROR]\x1b[0m ',
}[level];
console.log(`${prefix}${message}`);
}
// ═══════════════════════════════════════════════════════════════════════════════
// STATIC METHODS
// ═══════════════════════════════════════════════════════════════════════════════
/**
* CLI main function
*/
static async main() {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
GotchasDocumenter.showHelp();
process.exit(0);
}
// Parse arguments
let command = 'update';
let rootPath = process.cwd();
let outputPath = null;
let severity = null;
let category = null;
let format = 'md';
let quiet = false;
let query = null;
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '--root' && args[i + 1]) {
rootPath = args[++i];
} else if (arg === '--output' && args[i + 1]) {
outputPath = args[++i];
} else if (arg === '--severity' && args[i + 1]) {
severity = args[++i];
} else if (arg === '--category' && args[i + 1]) {
category = args[++i];
} else if (arg === '--format' && args[i + 1]) {
format = args[++i];
} else if (arg === '--quiet' || arg === '-q') {
quiet = true;
} else if (!arg.startsWith('-')) {
if (['update', 'list', 'search', 'category'].includes(arg)) {
command = arg;
} else if (command === 'search' || command === 'category') {
query = arg;
}
}
}
try {
const documenter = new GotchasDocumenter(rootPath, { outputPath, quiet });
switch (command) {
case 'update': {
// Merge with existing if present
const existingJson = path.join(rootPath, CONFIG.outputJsonPath);
documenter.mergeWithExisting(existingJson);
// Scan and extract
await documenter.scanInsightsFiles();
documenter.deduplicateGotchas();
// Save
const savedPath = documenter.saveGotchas(outputPath);
if (!quiet) {
console.log(`\nGotchas updated: ${savedPath}`);
console.log(`Total: ${documenter.gotchas.size} gotchas`);
}
break;
}
case 'list': {
// Load existing
const existingJson = path.join(rootPath, CONFIG.outputJsonPath);
documenter.mergeWithExisting(existingJson);
let gotchas = documenter.getAll();
// Filter by severity if specified
if (severity) {
gotchas = documenter.listBySeverity(severity);
}
// Output
if (format === 'json') {
console.log(JSON.stringify(gotchas, null, 2));
} else {
console.log(`\n=== Known Gotchas (${gotchas.length}) ===\n`);
for (const gotcha of gotchas) {
console.log(`[${gotcha.severity.toUpperCase()}] ${gotcha.title}`);
console.log(` Category: ${gotcha.category}`);
if (gotcha.storyId) console.log(` Story: ${gotcha.storyId}`);
console.log('');
}
}
break;
}
case 'search': {
if (!query) {
console.error('Error: Search query required');
process.exit(1);
}
// Load existing
const existingJson = path.join(rootPath, CONFIG.outputJsonPath);
documenter.mergeWithExisting(existingJson);
const results = documenter.search(query);
if (format === 'json') {
console.log(JSON.stringify(results, null, 2));
} else {
console.log(`\n=== Search Results for "${query}" (${results.length}) ===\n`);
for (const gotcha of results) {
console.log(`[${gotcha.severity.toUpperCase()}] ${gotcha.title}`);
console.log(` ${gotcha.reason.substring(0, 100)}...`);
console.log('');
}
}
break;
}
case 'category': {
if (!query && !category) {
console.error('Error: Category name required');
process.exit(1);
}
// Load existing
const existingJson = path.join(rootPath, CONFIG.outputJsonPath);
documenter.mergeWithExisting(existingJson);
const catResults = documenter.listByCategory(category || query);
if (format === 'json') {
console.log(JSON.stringify(catResults, null, 2));
} else {
console.log(`\n=== ${category || query} Gotchas (${catResults.length}) ===\n`);
for (const gotcha of catResults) {
console.log(`[${gotcha.severity.toUpperCase()}] ${gotcha.title}`);
if (gotcha.reason) console.log(` ${gotcha.reason.substring(0, 100)}...`);
console.log('');
}
}
break;
}
default:
console.error(`Unknown command: ${command}`);
process.exit(1);
}
} catch (error) {
console.error(`\nError: ${error.message}`);
process.exit(1);
}
}
/**
* Show CLI help
*/
static showHelp() {
console.log(`
Gotchas Documenter - AIOS Memory Layer (Story 7.4)
Automatically extracts gotchas from session insights and consolidates
them into a searchable, categorized knowledge base.
Usage:
node gotchas-documenter.js [command] [options]
*list-gotchas [command] [options]
Commands:
update Update gotchas from all insights (default)
list List all gotchas
search <query> Search gotchas
category <cat> List by category
Options:
--root <path> Project root (default: cwd)
--output <path> Custom output path
--severity <s> Filter by severity (high, medium, low)
--format <f> Output format (md, json)
--quiet, -q Suppress output
--help, -h Show this help message
Categories:
- State Management
- API
- Database
- Frontend/React
- Testing
- Build/Deploy
- TypeScript
- Authentication
- Performance
- Security
- Other
Examples:
node gotchas-documenter.js update
node gotchas-documenter.js list --severity high
node gotchas-documenter.js search "zustand persist"
node gotchas-documenter.js category TypeScript
node gotchas-documenter.js list --format json > gotchas.json
Acceptance Criteria Coverage:
AC1: Extracts gotchas from session insights automatically
AC2: Generates .aios/gotchas.md consolidated
AC3: Format: Wrong, Right, Reason for each gotcha
AC4: Categorized by area (API, Frontend, Database, etc.)
AC5: Referenced by Self-Critique (Epic 4)
AC6: Updated automatically after session insights
AC7: Command *list-gotchas for quick lookup
`);
}
}
// ═══════════════════════════════════════════════════════════════════════════════════
// HELPER FUNCTIONS
// ═══════════════════════════════════════════════════════════════════════════════════
/**
* Quick helper to update gotchas
*
* @param {string} rootPath - Project root path
* @param {Object} options - Options
* @returns {Promise<string>} Path to saved gotchas file
*/
async function updateGotchas(rootPath, options = {}) {
const documenter = new GotchasDocumenter(rootPath, options);
// Merge with existing
const existingJson = path.join(rootPath, CONFIG.outputJsonPath);
documenter.mergeWithExisting(existingJson);
// Scan and extract
await documenter.scanInsightsFiles();
documenter.deduplicateGotchas();
// Save
return documenter.saveGotchas(options.outputPath);
}
/**
* Get gotchas for self-critique integration (AC5)
*
* @param {string} rootPath - Project root path
* @param {string} [category] - Filter by category
* @returns {Object[]} Gotchas for self-critique
*/
function getGotchasForSelfCritique(rootPath, category = null) {
const documenter = new GotchasDocumenter(rootPath, { quiet: true });
// Load existing
const existingJson = path.join(rootPath, CONFIG.outputJsonPath);
documenter.mergeWithExisting(existingJson);
if (category) {
return documenter.listByCategory(category);
}
return documenter.getAll();
}
// ═══════════════════════════════════════════════════════════════════════════════════
// EXPORTS
// ═══════════════════════════════════════════════════════════════════════════════════
module.exports = {
GotchasDocumenter,
// Enums
Category,
Severity,
// Helper functions
updateGotchas,
getGotchasForSelfCritique,
// Config
CONFIG,
};
// Run CLI if executed directly
if (require.main === module) {
GotchasDocumenter.main();
}