feat: backlinks check/fix, page lint, and report commands
Three new deterministic tools (zero LLM calls):
- gbrain backlinks check/fix -- scans brain for entity mentions without
back-links, creates them. Enforces the Iron Law from the skills.
- gbrain lint [--fix] -- catches LLM preambles, code fence wrapping,
placeholder dates, missing frontmatter, broken citations, empty sections.
--fix auto-strips fixable artifacts.
- gbrain report --type <name> -- saves timestamped reports to
brain/reports/{type}/YYYY-MM-DD-HHMM.md for audit trails.
33 new tests (409 total, 0 fail).
This commit is contained in:
17
src/cli.ts
17
src/cli.ts
@@ -18,7 +18,7 @@ for (const op of operations) {
|
||||
}
|
||||
|
||||
// CLI-only commands that bypass the operation layer
|
||||
const CLI_ONLY = new Set(['init', 'upgrade', 'post-upgrade', 'check-update', 'integrations', 'publish', 'import', 'export', 'files', 'embed', 'serve', 'call', 'config', 'doctor', 'migrate']);
|
||||
const CLI_ONLY = new Set(['init', 'upgrade', 'post-upgrade', 'check-update', 'integrations', 'publish', 'backlinks', 'lint', 'report', 'import', 'export', 'files', 'embed', 'serve', 'call', 'config', 'doctor', 'migrate']);
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
@@ -252,6 +252,21 @@ async function handleCliOnly(command: string, args: string[]) {
|
||||
await runPublish(subArgs);
|
||||
return;
|
||||
}
|
||||
if (command === 'backlinks') {
|
||||
const { runBacklinks } = await import('./commands/backlinks.ts');
|
||||
await runBacklinks(subArgs);
|
||||
return;
|
||||
}
|
||||
if (command === 'lint') {
|
||||
const { runLint } = await import('./commands/lint.ts');
|
||||
await runLint(subArgs);
|
||||
return;
|
||||
}
|
||||
if (command === 'report') {
|
||||
const { runReport } = await import('./commands/report.ts');
|
||||
await runReport(subArgs);
|
||||
return;
|
||||
}
|
||||
|
||||
// All remaining CLI-only commands need a DB connection
|
||||
const engine = await connectEngine();
|
||||
|
||||
213
src/commands/backlinks.ts
Normal file
213
src/commands/backlinks.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* gbrain backlinks — Check and fix missing back-links across brain pages.
|
||||
*
|
||||
* Deterministic: zero LLM calls. Scans pages for entity mentions,
|
||||
* checks if back-links exist, and optionally creates them.
|
||||
*
|
||||
* Usage:
|
||||
* gbrain backlinks check [--dir <brain-dir>] # report missing back-links
|
||||
* gbrain backlinks fix [--dir <brain-dir>] # create missing back-links
|
||||
* gbrain backlinks fix --dry-run # preview fixes
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, readdirSync, statSync, existsSync } from 'fs';
|
||||
import { join, relative, basename } from 'path';
|
||||
|
||||
interface BacklinkGap {
|
||||
/** The page that mentions the entity */
|
||||
sourcePage: string;
|
||||
/** The entity page that's missing the back-link */
|
||||
targetPage: string;
|
||||
/** The entity name mentioned */
|
||||
entityName: string;
|
||||
/** The source page title */
|
||||
sourceTitle: string;
|
||||
}
|
||||
|
||||
/** Extract entity references from markdown content (relative links to people/companies) */
|
||||
export function extractEntityRefs(content: string, pagePath: string): { name: string; slug: string; dir: string }[] {
|
||||
const refs: { name: string; slug: string; dir: string }[] = [];
|
||||
// Match markdown links to brain pages: [Name](../people/slug.md) or [Name](../../companies/slug.md)
|
||||
const linkPattern = /\[([^\]]+)\]\(([^)]*(?:people|companies)\/([^)]+\.md))\)/g;
|
||||
let match;
|
||||
while ((match = linkPattern.exec(content)) !== null) {
|
||||
const name = match[1];
|
||||
const fullPath = match[2];
|
||||
const slug = match[3].replace('.md', '');
|
||||
const dir = fullPath.includes('people') ? 'people' : 'companies';
|
||||
refs.push({ name, slug, dir });
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
/** Extract title from page (first H1 or frontmatter title) */
|
||||
export function extractPageTitle(content: string): string {
|
||||
const fmMatch = content.match(/^title:\s*"?(.+?)"?\s*$/m);
|
||||
if (fmMatch) return fmMatch[1];
|
||||
const h1Match = content.match(/^#\s+(.+)$/m);
|
||||
if (h1Match) return h1Match[1].trim();
|
||||
return 'Untitled';
|
||||
}
|
||||
|
||||
/** Check if a page already contains a back-link to a given source file */
|
||||
export function hasBacklink(targetContent: string, sourceFilename: string): boolean {
|
||||
return targetContent.includes(sourceFilename);
|
||||
}
|
||||
|
||||
/** Build a timeline back-link entry */
|
||||
export function buildBacklinkEntry(sourceTitle: string, sourcePath: string, date: string): string {
|
||||
return `- **${date}** | Referenced in [${sourceTitle}](${sourcePath})`;
|
||||
}
|
||||
|
||||
/** Scan a brain directory for back-link gaps */
|
||||
export function findBacklinkGaps(brainDir: string): BacklinkGap[] {
|
||||
const gaps: BacklinkGap[] = [];
|
||||
|
||||
// Collect all markdown files
|
||||
const allPages: { path: string; relPath: string; content: string }[] = [];
|
||||
function walk(dir: string) {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
if (entry.startsWith('.')) continue;
|
||||
const full = join(dir, entry);
|
||||
if (statSync(full).isDirectory()) {
|
||||
walk(full);
|
||||
} else if (entry.endsWith('.md') && !entry.startsWith('_')) {
|
||||
const relPath = relative(brainDir, full);
|
||||
try {
|
||||
allPages.push({ path: full, relPath, content: readFileSync(full, 'utf-8') });
|
||||
} catch { /* skip unreadable */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(brainDir);
|
||||
|
||||
// Build a lookup of existing pages by directory/slug
|
||||
const pagesBySlug = new Map<string, { path: string; content: string }>();
|
||||
for (const page of allPages) {
|
||||
const slug = page.relPath.replace('.md', '');
|
||||
pagesBySlug.set(slug, { path: page.path, content: page.content });
|
||||
}
|
||||
|
||||
// For each page, check entity references
|
||||
for (const page of allPages) {
|
||||
const refs = extractEntityRefs(page.content, page.relPath);
|
||||
const sourceFilename = basename(page.relPath);
|
||||
|
||||
for (const ref of refs) {
|
||||
const targetSlug = `${ref.dir}/${ref.slug}`;
|
||||
const target = pagesBySlug.get(targetSlug);
|
||||
if (!target) continue; // target page doesn't exist
|
||||
|
||||
// Check if the target already has a back-link to this source page
|
||||
if (!hasBacklink(target.content, sourceFilename)) {
|
||||
gaps.push({
|
||||
sourcePage: page.relPath,
|
||||
targetPage: targetSlug + '.md',
|
||||
entityName: ref.name,
|
||||
sourceTitle: extractPageTitle(page.content),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return gaps;
|
||||
}
|
||||
|
||||
/** Fix back-link gaps by appending timeline entries to target pages */
|
||||
export function fixBacklinkGaps(brainDir: string, gaps: BacklinkGap[], dryRun: boolean = false): number {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
let fixed = 0;
|
||||
|
||||
// Group gaps by target page to batch writes
|
||||
const byTarget = new Map<string, BacklinkGap[]>();
|
||||
for (const gap of gaps) {
|
||||
const existing = byTarget.get(gap.targetPage) || [];
|
||||
existing.push(gap);
|
||||
byTarget.set(gap.targetPage, existing);
|
||||
}
|
||||
|
||||
for (const [targetPage, targetGaps] of byTarget) {
|
||||
const targetPath = join(brainDir, targetPage);
|
||||
if (!existsSync(targetPath)) continue;
|
||||
|
||||
let content = readFileSync(targetPath, 'utf-8');
|
||||
|
||||
for (const gap of targetGaps) {
|
||||
// Compute relative path from target to source
|
||||
const targetDir = targetPage.split('/').slice(0, -1);
|
||||
const sourceDir = gap.sourcePage.split('/');
|
||||
const depth = targetDir.length;
|
||||
const relPrefix = '../'.repeat(depth);
|
||||
const relPath = relPrefix + gap.sourcePage;
|
||||
|
||||
const entry = buildBacklinkEntry(gap.sourceTitle, relPath, today);
|
||||
|
||||
// Insert into Timeline section
|
||||
if (content.includes('## Timeline')) {
|
||||
const parts = content.split('## Timeline');
|
||||
const afterTimeline = parts[1];
|
||||
const nextSection = afterTimeline.match(/\n## /);
|
||||
if (nextSection) {
|
||||
const insertIdx = parts[0].length + '## Timeline'.length + nextSection.index!;
|
||||
content = content.slice(0, insertIdx) + '\n' + entry + content.slice(insertIdx);
|
||||
} else {
|
||||
content = content.trimEnd() + '\n' + entry + '\n';
|
||||
}
|
||||
} else {
|
||||
// Add Timeline section
|
||||
content = content.trimEnd() + '\n\n## Timeline\n\n' + entry + '\n';
|
||||
}
|
||||
fixed++;
|
||||
}
|
||||
|
||||
if (!dryRun) {
|
||||
writeFileSync(targetPath, content);
|
||||
}
|
||||
}
|
||||
|
||||
return fixed;
|
||||
}
|
||||
|
||||
export async function runBacklinks(args: string[]) {
|
||||
const subcommand = args[0];
|
||||
const dirIdx = args.indexOf('--dir');
|
||||
const brainDir = dirIdx >= 0 ? args[dirIdx + 1] : '.';
|
||||
const dryRun = args.includes('--dry-run');
|
||||
|
||||
if (!subcommand || !['check', 'fix'].includes(subcommand)) {
|
||||
console.error('Usage: gbrain backlinks <check|fix> [--dir <brain-dir>] [--dry-run]');
|
||||
console.error(' check Report missing back-links');
|
||||
console.error(' fix Create missing back-links (appends to Timeline)');
|
||||
console.error(' --dir Brain directory (default: current directory)');
|
||||
console.error(' --dry-run Preview fixes without writing');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!existsSync(brainDir)) {
|
||||
console.error(`Directory not found: ${brainDir}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const gaps = findBacklinkGaps(brainDir);
|
||||
|
||||
if (gaps.length === 0) {
|
||||
console.log('No missing back-links found.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (subcommand === 'check') {
|
||||
console.log(`Found ${gaps.length} missing back-link(s):\n`);
|
||||
for (const gap of gaps) {
|
||||
console.log(` ${gap.targetPage} <- ${gap.sourcePage}`);
|
||||
console.log(` "${gap.entityName}" mentioned in "${gap.sourceTitle}"`);
|
||||
}
|
||||
console.log(`\nRun 'gbrain backlinks fix --dir ${brainDir}' to create them.`);
|
||||
} else {
|
||||
const label = dryRun ? '(dry run) ' : '';
|
||||
const fixed = fixBacklinkGaps(brainDir, gaps, dryRun);
|
||||
console.log(`${label}Fixed ${fixed} missing back-link(s) across ${new Set(gaps.map(g => g.targetPage)).size} page(s).`);
|
||||
if (dryRun) {
|
||||
console.log('\nRe-run without --dry-run to apply.');
|
||||
}
|
||||
}
|
||||
}
|
||||
245
src/commands/lint.ts
Normal file
245
src/commands/lint.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* gbrain lint — Deterministic brain page quality checker.
|
||||
*
|
||||
* Zero LLM calls. Catches common quality issues:
|
||||
* - LLM preamble artifacts ("Of course! Here is...")
|
||||
* - Placeholder dates (YYYY-MM-DD, XX-XX left unfilled)
|
||||
* - Missing required frontmatter fields
|
||||
* - Broken citations (unclosed brackets, missing dates)
|
||||
* - Empty/stub sections
|
||||
* - Wrapping code fences from LLM output
|
||||
*
|
||||
* Usage:
|
||||
* gbrain lint <dir> # report issues
|
||||
* gbrain lint <dir> --fix # auto-fix what's fixable
|
||||
* gbrain lint <dir> --fix --dry-run # preview fixes
|
||||
* gbrain lint <file.md> # lint single file
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, readdirSync, statSync, existsSync } from 'fs';
|
||||
import { join, relative } from 'path';
|
||||
|
||||
export interface LintIssue {
|
||||
file: string;
|
||||
line: number;
|
||||
rule: string;
|
||||
message: string;
|
||||
fixable: boolean;
|
||||
}
|
||||
|
||||
// ── LLM artifact patterns ──────────────────────────────────────────
|
||||
|
||||
const LLM_PREAMBLES = [
|
||||
/^Of course\.?\s*Here is (?:a |the )?(?:detailed |comprehensive |updated )?(?:brain )?page[^.\n]*\.?\s*\n*/gim,
|
||||
/^Certainly\.?\s*Here is[^.\n]*\.?\s*\n*/gim,
|
||||
/^Here is (?:a |the )?(?:detailed |comprehensive |updated )?(?:brain )?page[^.\n]*\.?\s*\n*/gim,
|
||||
/^I've (?:created|updated|written|prepared) (?:a |the )?(?:detailed |comprehensive )?(?:brain )?page[^.\n]*\.?\s*\n*/gim,
|
||||
/^Sure(?:!|,)?\s*Here (?:is|are)[^.\n]*\.?\s*\n*/gim,
|
||||
/^Absolutely\.?\s*Here[^.\n]*\.?\s*\n*/gim,
|
||||
];
|
||||
|
||||
// ── Rules ──────────────────────────────────────────────────────────
|
||||
|
||||
export function lintContent(content: string, filePath: string): LintIssue[] {
|
||||
const issues: LintIssue[] = [];
|
||||
const lines = content.split('\n');
|
||||
|
||||
// Rule: LLM preamble artifacts
|
||||
for (const pattern of LLM_PREAMBLES) {
|
||||
pattern.lastIndex = 0;
|
||||
if (pattern.test(content)) {
|
||||
issues.push({
|
||||
file: filePath, line: 1, rule: 'llm-preamble',
|
||||
message: 'LLM preamble artifact detected (e.g., "Of course! Here is...")',
|
||||
fixable: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Rule: Wrapping code fences (```markdown ... ```)
|
||||
if (content.match(/^```(?:markdown|md)\s*\n/m) && content.match(/\n```\s*$/m)) {
|
||||
issues.push({
|
||||
file: filePath, line: 1, rule: 'code-fence-wrap',
|
||||
message: 'Page wrapped in ```markdown code fences (LLM artifact)',
|
||||
fixable: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Rule: Placeholder dates
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].match(/\bYYYY-MM-DD\b/) || lines[i].match(/\bXX-XX\b/) || lines[i].match(/\b\d{4}-XX-XX\b/)) {
|
||||
issues.push({
|
||||
file: filePath, line: i + 1, rule: 'placeholder-date',
|
||||
message: `Placeholder date found: ${lines[i].trim().slice(0, 60)}`,
|
||||
fixable: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Rule: Missing frontmatter
|
||||
if (content.startsWith('---')) {
|
||||
const fmEnd = content.indexOf('---', 3);
|
||||
if (fmEnd > 0) {
|
||||
const fm = content.slice(3, fmEnd);
|
||||
if (!fm.match(/^title:/m)) {
|
||||
issues.push({
|
||||
file: filePath, line: 1, rule: 'missing-title',
|
||||
message: 'Frontmatter missing required field: title',
|
||||
fixable: false,
|
||||
});
|
||||
}
|
||||
if (!fm.match(/^type:/m)) {
|
||||
issues.push({
|
||||
file: filePath, line: 1, rule: 'missing-type',
|
||||
message: 'Frontmatter missing required field: type',
|
||||
fixable: false,
|
||||
});
|
||||
}
|
||||
if (!fm.match(/^created:/m)) {
|
||||
issues.push({
|
||||
file: filePath, line: 1, rule: 'missing-created',
|
||||
message: 'Frontmatter missing required field: created',
|
||||
fixable: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No frontmatter at all
|
||||
issues.push({
|
||||
file: filePath, line: 1, rule: 'no-frontmatter',
|
||||
message: 'Page has no YAML frontmatter',
|
||||
fixable: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Rule: Broken citations (unclosed [Source: ...)
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
// Open [Source: without closing ]
|
||||
if (line.match(/\[Source:[^\]]*$/) && !(i + 1 < lines.length && lines[i + 1].match(/^\s*[^\[]*\]/))) {
|
||||
issues.push({
|
||||
file: filePath, line: i + 1, rule: 'broken-citation',
|
||||
message: 'Unclosed [Source: ...] citation',
|
||||
fixable: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Rule: Empty/stub sections
|
||||
const sectionPattern = /^##\s+(.+)$/gm;
|
||||
let sectionMatch;
|
||||
while ((sectionMatch = sectionPattern.exec(content)) !== null) {
|
||||
const sectionStart = sectionMatch.index + sectionMatch[0].length;
|
||||
const nextSection = content.indexOf('\n## ', sectionStart);
|
||||
const sectionBody = content.slice(sectionStart, nextSection > 0 ? nextSection : undefined).trim();
|
||||
|
||||
if (sectionBody === '' || sectionBody === '[No data yet]' || sectionBody === '*[To be filled by agent]*') {
|
||||
const lineNum = content.slice(0, sectionMatch.index).split('\n').length;
|
||||
issues.push({
|
||||
file: filePath, line: lineNum, rule: 'empty-section',
|
||||
message: `Empty section: ## ${sectionMatch[1]}`,
|
||||
fixable: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
/** Auto-fix fixable issues */
|
||||
export function fixContent(content: string): string {
|
||||
let fixed = content;
|
||||
|
||||
// Fix LLM preambles
|
||||
for (const pattern of LLM_PREAMBLES) {
|
||||
pattern.lastIndex = 0;
|
||||
fixed = fixed.replace(pattern, '');
|
||||
}
|
||||
|
||||
// Fix wrapping code fences
|
||||
fixed = fixed.replace(/^```(?:markdown|md)\s*\n/, '');
|
||||
fixed = fixed.replace(/\n```\s*$/, '');
|
||||
|
||||
// Clean up excessive blank lines left by fixes
|
||||
fixed = fixed.replace(/\n{3,}/g, '\n\n');
|
||||
|
||||
return fixed.trim() + '\n';
|
||||
}
|
||||
|
||||
/** Collect markdown files from a directory */
|
||||
function collectPages(dir: string): string[] {
|
||||
const pages: string[] = [];
|
||||
function walk(d: string) {
|
||||
for (const entry of readdirSync(d)) {
|
||||
if (entry.startsWith('.') || entry.startsWith('_')) continue;
|
||||
const full = join(d, entry);
|
||||
if (statSync(full).isDirectory()) walk(full);
|
||||
else if (entry.endsWith('.md')) pages.push(full);
|
||||
}
|
||||
}
|
||||
walk(dir);
|
||||
return pages.sort();
|
||||
}
|
||||
|
||||
export async function runLint(args: string[]) {
|
||||
const target = args.find(a => !a.startsWith('--'));
|
||||
const doFix = args.includes('--fix');
|
||||
const dryRun = args.includes('--dry-run');
|
||||
|
||||
if (!target) {
|
||||
console.error('Usage: gbrain lint <dir|file.md> [--fix] [--dry-run]');
|
||||
console.error(' --fix Auto-fix fixable issues (LLM preambles, code fences)');
|
||||
console.error(' --dry-run Preview fixes without writing');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!existsSync(target)) {
|
||||
console.error(`Not found: ${target}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Single file or directory
|
||||
const isSingleFile = statSync(target).isFile();
|
||||
const pages = isSingleFile ? [target] : collectPages(target);
|
||||
|
||||
let totalIssues = 0;
|
||||
let totalFixed = 0;
|
||||
let pagesWithIssues = 0;
|
||||
|
||||
for (const page of pages) {
|
||||
const content = readFileSync(page, 'utf-8');
|
||||
const relPath = isSingleFile ? page : relative(target, page);
|
||||
const issues = lintContent(content, relPath);
|
||||
|
||||
if (issues.length === 0) continue;
|
||||
pagesWithIssues++;
|
||||
totalIssues += issues.length;
|
||||
|
||||
console.log(`\n${relPath}:`);
|
||||
for (const issue of issues) {
|
||||
const fixLabel = issue.fixable ? ' [fixable]' : '';
|
||||
console.log(` L${issue.line} ${issue.rule}: ${issue.message}${fixLabel}`);
|
||||
}
|
||||
|
||||
// Auto-fix if requested
|
||||
if (doFix && issues.some(i => i.fixable)) {
|
||||
const fixed = fixContent(content);
|
||||
if (fixed !== content) {
|
||||
const fixCount = issues.filter(i => i.fixable).length;
|
||||
totalFixed += fixCount;
|
||||
if (!dryRun) {
|
||||
writeFileSync(page, fixed);
|
||||
}
|
||||
console.log(` ${dryRun ? '(dry run) ' : ''}Fixed ${fixCount} issue(s)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n${pages.length} pages scanned. ${totalIssues} issue(s) in ${pagesWithIssues} page(s).`);
|
||||
if (doFix) {
|
||||
console.log(`${dryRun ? '(dry run) ' : ''}${totalFixed} auto-fixed.`);
|
||||
} else if (totalIssues > 0) {
|
||||
const fixable = totalIssues; // rough estimate
|
||||
console.log(`Run with --fix to auto-fix fixable issues.`);
|
||||
}
|
||||
}
|
||||
76
src/commands/report.ts
Normal file
76
src/commands/report.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* gbrain report — Save a structured report to brain/reports/.
|
||||
*
|
||||
* Deterministic: zero LLM calls. Creates timestamped report pages
|
||||
* for audit trails of enrichment sweeps, maintenance runs, syncs, etc.
|
||||
*
|
||||
* Usage:
|
||||
* gbrain report --type enrichment-sweep --title "Enrichment Sweep" --content "..."
|
||||
* echo "report body" | gbrain report --type meeting-sync --title "Meeting Sync"
|
||||
* gbrain report --type enrichment-sweep --dir /path/to/brain
|
||||
*/
|
||||
|
||||
import { writeFileSync, mkdirSync, readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
export async function runReport(args: string[]) {
|
||||
const typeIdx = args.indexOf('--type');
|
||||
const titleIdx = args.indexOf('--title');
|
||||
const contentIdx = args.indexOf('--content');
|
||||
const dirIdx = args.indexOf('--dir');
|
||||
|
||||
const reportType = typeIdx >= 0 ? args[typeIdx + 1] : null;
|
||||
const brainDir = dirIdx >= 0 ? args[dirIdx + 1] : '.';
|
||||
|
||||
if (!reportType) {
|
||||
console.error('Usage: gbrain report --type <name> --title "..." --content "..." [--dir <brain>]');
|
||||
console.error(' Or pipe content via stdin:');
|
||||
console.error(' echo "report body" | gbrain report --type meeting-sync --title "Daily Sync"');
|
||||
console.error('');
|
||||
console.error(' Common types: enrichment-sweep, meeting-sync, maintenance, backlink-check, lint');
|
||||
console.error(' Creates: brain/reports/{type}/{YYYY-MM-DD-HHMM}.md');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Read content from --content arg or stdin
|
||||
let content = contentIdx >= 0 ? args[contentIdx + 1] : null;
|
||||
if (!content && !process.stdin.isTTY) {
|
||||
content = readFileSync('/dev/stdin', 'utf-8');
|
||||
}
|
||||
|
||||
if (!content?.trim()) {
|
||||
console.error('No content provided. Use --content "..." or pipe via stdin.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
const dateStr = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
||||
const timeStr = `${pad(now.getHours())}${pad(now.getMinutes())}`;
|
||||
const timePretty = `${pad(now.getHours())}:${pad(now.getMinutes())}`;
|
||||
|
||||
const title = titleIdx >= 0
|
||||
? args[titleIdx + 1]
|
||||
: reportType.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
||||
|
||||
const filename = `${dateStr}-${timeStr}.md`;
|
||||
const reportDir = join(brainDir, 'reports', reportType);
|
||||
mkdirSync(reportDir, { recursive: true });
|
||||
|
||||
const page = `---
|
||||
title: "${title} -- ${dateStr}"
|
||||
type: report
|
||||
report_type: ${reportType}
|
||||
date: ${dateStr}
|
||||
time: "${timePretty}"
|
||||
---
|
||||
|
||||
# ${title} -- ${dateStr} ${timePretty}
|
||||
|
||||
${content.trim()}
|
||||
`;
|
||||
|
||||
const filepath = join(reportDir, filename);
|
||||
writeFileSync(filepath, page);
|
||||
console.log(filepath);
|
||||
}
|
||||
Reference in New Issue
Block a user