It's referenced in CLAUDE.md, but not present in the repo. Claude offered to build it aand came up with this:
``
#!/usr/bin/env node
/**
- auto-link-people.cjs
- Converts known people names to [[Name]] WikiLinks in vault markdown files.
- Scans 05-Areas/People/{Internal,External,CPO_Network} for the registry.
- Usage:
- node .scripts/auto-link-people.cjs # Process single file
- node .scripts/auto-link-people.cjs --today # Process today's key files
- node .scripts/auto-link-people.cjs --dry-run # Preview without writing
- Module usage:
- const { autoLinkContent } = require('./.scripts/auto-link-people.cjs');
- const result = autoLinkContent(markdownText, registry);
*/
const fs = require('fs');
const path = require('path');
const VAULT_ROOT = path.resolve(__dirname, '..');
const PEOPLE_DIRS = [
path.join(VAULT_ROOT, '05-Areas', 'People', 'Internal'),
path.join(VAULT_ROOT, '05-Areas', 'People', 'External'),
path.join(VAULT_ROOT, '05-Areas', 'People', 'CPO_Network'),
];
/**
- Build a registry of known people from the People directories.
- Returns { fullNames: Set, firstNameToFull: Map, ownerName: string }
*/
function buildRegistry() {
const fullNames = new Set();
const firstNameToFull = new Map(); // firstName -> fullName or null (ambiguous)
for (const dir of PEOPLE_DIRS) {
if (!fs.existsSync(dir)) continue;
for (const file of fs.readdirSync(dir)) {
if (!file.endsWith('.md') || file === 'README.md') continue;
const name = file.replace(/.md$/, '');
fullNames.add(name);
const firstName = name.split(' ')[0];
if (firstNameToFull.has(firstName)) {
// Multiple people share this first name — mark ambiguous
firstNameToFull.set(firstName, null);
} else {
firstNameToFull.set(firstName, name);
}
}
}
// Load owner name to skip self-linking
let ownerName = '';
const profilePath = path.join(VAULT_ROOT, 'System', 'user-profile.yaml');
if (fs.existsSync(profilePath)) {
const yaml = fs.readFileSync(profilePath, 'utf8');
const match = yaml.match(/^name:\s*"?([^"\n]+)"?/m);
if (match) ownerName = match[1].trim();
}
// Load aliases from Key Context lines like "Goes by X"
const aliases = new Map(); // alias -> fullName
for (const dir of PEOPLE_DIRS) {
if (!fs.existsSync(dir)) continue;
for (const file of fs.readdirSync(dir)) {
if (!file.endsWith('.md') || file === 'README.md') continue;
const name = file.replace(/.md$/, '');
const content = fs.readFileSync(path.join(dir, file), 'utf8');
const goesBy = content.match(/Goes by "?([^"\n]+)"?/i);
if (goesBy) {
const alias = goesBy[1].trim();
// Only add if the alias isn't ambiguous with another full name or first name
if (!fullNames.has(alias)) {
aliases.set(alias, name);
}
}
}
}
return { fullNames, firstNameToFull, ownerName, aliases };
}
/**
- Detect unknown full names in text (e.g., "Jessica Jolly") — first names
- that appear with an unrecognized last name. These first names should NOT
- be auto-linked as standalone first names elsewhere in the same file.
*/
function findPoisonedFirstNames(text, registry) {
const poisoned = new Set();
// Match capitalized "First Last" patterns
const namePattern = /\b([A-Z][a-z]+)\s+([A-Z][a-z]+)\b/g;
let m;
while ((m = namePattern.exec(text)) !== null) {
const full = ${m[1]} ${m[2]};
if (!registry.fullNames.has(full)) {
// Unknown full name — poison this first name for standalone matching
if (registry.firstNameToFull.has(m[1])) {
poisoned.add(m[1]);
}
}
}
return poisoned;
}
/**
- Process markdown content and return the auto-linked version.
- Skips frontmatter, code blocks, and existing WikiLinks.
*/
function autoLinkContent(text, registry) {
if (!registry) registry = buildRegistry();
const { fullNames, firstNameToFull, ownerName, aliases } = registry;
const poisoned = findPoisonedFirstNames(text, registry);
const lines = text.split('\n');
const result = [];
let inFrontmatter = false;
let frontmatterCount = 0;
let inCodeBlock = false;
for (let i = 0; i < lines.length; i++) {
let line = lines[i];
// Track frontmatter (--- delimited)
if (line.trim() === '---') {
if (i === 0 || (frontmatterCount === 1 && inFrontmatter)) {
frontmatterCount++;
inFrontmatter = frontmatterCount === 1;
if (frontmatterCount === 2) inFrontmatter = false;
result.push(line);
continue;
}
}
if (inFrontmatter) {
result.push(line);
continue;
}
// Track code blocks
if (line.trim().startsWith('```')) {
inCodeBlock = !inCodeBlock;
result.push(line);
continue;
}
if (inCodeBlock) {
result.push(line);
continue;
}
// Process this line for name linking
line = linkNamesInLine(line, fullNames, firstNameToFull, ownerName, aliases, poisoned);
result.push(line);
}
return result.join('\n');
}
/**
- Replace known names with WikiLinks in a single line.
- Skips text already inside [[ ]] or markdown links.
*/
function linkNamesInLine(line, fullNames, firstNameToFull, ownerName, aliases, poisoned) {
// Split line into segments: WikiLinks and non-WikiLinks
// This prevents double-linking already-linked names
const segments = line.split(/([[[^\]]+]])/);
return segments.map(seg => {
// If this segment is already a WikiLink, leave it
if (seg.startsWith('[[') && seg.endsWith(']]')) return seg;
// Replace full names first (longest match first)
const sortedNames = [...fullNames].sort((a, b) => b.length - a.length);
for (const name of sortedNames) {
if (name === ownerName) continue;
// Word-boundary match for the full name, case-sensitive
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(`\\b${escaped}\\b`, 'g');
seg = seg.replace(re, `[[${name}]]`);
}
// Replace aliases (e.g., "Alex" -> [[Grace Brown|Alex]])
for (const [alias, fullName] of aliases) {
if (fullName === ownerName) continue;
const escaped = alias.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(`\\b${escaped}\\b`, 'g');
// Only replace if not already inside a wikilink we just created
seg = seg.replace(re, (match, offset) => {
// Check if we're inside a [[ ]] from a prior replacement in this segment
const before = seg.substring(0, offset);
const openCount = (before.match(/\[\[/g) || []).length;
const closeCount = (before.match(/\]\]/g) || []).length;
if (openCount > closeCount) return match; // inside a wikilink
return `[[${fullName}|${alias}]]`;
});
}
// Replace unambiguous first names (not poisoned)
for (const [firstName, fullName] of firstNameToFull) {
if (fullName === null) continue; // ambiguous
if (fullName === ownerName) continue;
if (poisoned.has(firstName)) continue;
const escaped = firstName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(`\\b${escaped}\\b`, 'g');
seg = seg.replace(re, (match, offset) => {
const before = seg.substring(0, offset);
const openCount = (before.match(/\[\[/g) || []).length;
const closeCount = (before.match(/\]\]/g) || []).length;
if (openCount > closeCount) return match;
return `[[${fullName}|${firstName}]]`;
});
}
return seg;
}).join('');
}
/**
- Process a single file in-place.
*/
function processFile(filePath, dryRun = false) {
const abs = path.resolve(filePath);
if (!fs.existsSync(abs)) {
console.error(File not found: ${abs});
return { file: abs, changes: 0 };
}
const original = fs.readFileSync(abs, 'utf8');
const registry = buildRegistry();
const linked = autoLinkContent(original, registry);
if (original === linked) {
return { file: abs, changes: 0 };
}
// Count changes
const origLinks = (original.match(/[[/g) || []).length;
const newLinks = (linked.match(/[[/g) || []).length;
const changes = newLinks - origLinks;
if (!dryRun) {
fs.writeFileSync(abs, linked, 'utf8');
}
return { file: abs, changes, dryRun };
}
/**
- --today mode: process today's key files (daily plan, tasks, week priorities, recent meetings).
*/
function processToday(dryRun = false) {
const today = new Date().toISOString().slice(0, 10);
const candidates = [
path.join(VAULT_ROOT, '03-Tasks', 'Tasks.md'),
path.join(VAULT_ROOT, '02-Week_Priorities', 'Week_Priorities.md'),
];
// Add today's meetings
const meetingsDir = path.join(VAULT_ROOT, '00-Inbox', 'Meetings');
if (fs.existsSync(meetingsDir)) {
for (const file of fs.readdirSync(meetingsDir)) {
if (file.startsWith(today) && file.endsWith('.md')) {
candidates.push(path.join(meetingsDir, file));
}
}
}
// Add today's journal
const journalPath = path.join(VAULT_ROOT, 'Journal', ${today}.md);
if (fs.existsSync(journalPath)) candidates.push(journalPath);
// Add today's daily plan if it exists in any common location
const dailyPlanDir = path.join(VAULT_ROOT, '02-Week_Priorities');
if (fs.existsSync(dailyPlanDir)) {
for (const file of fs.readdirSync(dailyPlanDir)) {
if (file.includes(today) && file.endsWith('.md')) {
candidates.push(path.join(dailyPlanDir, file));
}
}
}
const results = [];
const registry = buildRegistry();
for (const filePath of candidates) {
if (!fs.existsSync(filePath)) continue;
const original = fs.readFileSync(filePath, 'utf8');
const linked = autoLinkContent(original, registry);
if (original !== linked) {
const origLinks = (original.match(/[[/g) || []).length;
const newLinks = (linked.match(/[[/g) || []).length;
const changes = newLinks - origLinks;
if (!dryRun) fs.writeFileSync(filePath, linked, 'utf8');
results.push({ file: filePath, changes, dryRun });
}
}
return results;
}
// --- CLI ---
if (require.main === module) {
const args = process.argv.slice(2);
const dryRun = args.includes('--dry-run');
const filtered = args.filter(a => a !== '--dry-run');
if (filtered.includes('--today')) {
const results = processToday(dryRun);
if (results.length === 0) {
console.log('No changes needed in today's files.');
} else {
for (const r of results) {
const label = r.dryRun ? '[dry-run] ' : '';
console.log(${label}${path.relative(VAULT_ROOT, r.file)}: +${r.changes} links);
}
console.log(\nTotal: ${results.length} files, +${results.reduce((s, r) => s + r.changes, 0)} links);
}
} else if (filtered.length > 0) {
for (const filePath of filtered) {
const r = processFile(filePath, dryRun);
if (r.changes === 0) {
console.log(${path.relative(VAULT_ROOT, r.file)}: no changes);
} else {
const label = r.dryRun ? '[dry-run] ' : '';
console.log(${label}${path.relative(VAULT_ROOT, r.file)}: +${r.changes} links);
}
}
} else {
console.log('Usage:');
console.log(' node auto-link-people.cjs Process a single file');
console.log(' node auto-link-people.cjs --today Process today's key files');
console.log(' node auto-link-people.cjs --dry-run Preview without writing');
}
}
module.exports = { autoLinkContent, buildRegistry, processFile, processToday };
``
It's referenced in CLAUDE.md, but not present in the repo. Claude offered to build it aand came up with this:
``
#!/usr/bin/env node
/**
*/
const fs = require('fs');
const path = require('path');
const VAULT_ROOT = path.resolve(__dirname, '..');
const PEOPLE_DIRS = [
path.join(VAULT_ROOT, '05-Areas', 'People', 'Internal'),
path.join(VAULT_ROOT, '05-Areas', 'People', 'External'),
path.join(VAULT_ROOT, '05-Areas', 'People', 'CPO_Network'),
];
/**
*/
function buildRegistry() {
const fullNames = new Set();
const firstNameToFull = new Map(); // firstName -> fullName or null (ambiguous)
for (const dir of PEOPLE_DIRS) {
if (!fs.existsSync(dir)) continue;
for (const file of fs.readdirSync(dir)) {
if (!file.endsWith('.md') || file === 'README.md') continue;
const name = file.replace(/.md$/, '');
fullNames.add(name);
}
// Load owner name to skip self-linking
let ownerName = '';
const profilePath = path.join(VAULT_ROOT, 'System', 'user-profile.yaml');
if (fs.existsSync(profilePath)) {
const yaml = fs.readFileSync(profilePath, 'utf8');
const match = yaml.match(/^name:\s*"?([^"\n]+)"?/m);
if (match) ownerName = match[1].trim();
}
// Load aliases from Key Context lines like "Goes by X"
const aliases = new Map(); // alias -> fullName
for (const dir of PEOPLE_DIRS) {
if (!fs.existsSync(dir)) continue;
for (const file of fs.readdirSync(dir)) {
if (!file.endsWith('.md') || file === 'README.md') continue;
const name = file.replace(/.md$/, '');
const content = fs.readFileSync(path.join(dir, file), 'utf8');
const goesBy = content.match(/Goes by "?([^"\n]+)"?/i);
if (goesBy) {
const alias = goesBy[1].trim();
// Only add if the alias isn't ambiguous with another full name or first name
if (!fullNames.has(alias)) {
aliases.set(alias, name);
}
}
}
}
return { fullNames, firstNameToFull, ownerName, aliases };
}
/**
*/
function findPoisonedFirstNames(text, registry) {
const poisoned = new Set();
// Match capitalized "First Last" patterns
const namePattern = /\b([A-Z][a-z]+)\s+([A-Z][a-z]+)\b/g;
let m;
while ((m = namePattern.exec(text)) !== null) {
const full =
${m[1]} ${m[2]};if (!registry.fullNames.has(full)) {
// Unknown full name — poison this first name for standalone matching
if (registry.firstNameToFull.has(m[1])) {
poisoned.add(m[1]);
}
}
}
return poisoned;
}
/**
*/
function autoLinkContent(text, registry) {
if (!registry) registry = buildRegistry();
const { fullNames, firstNameToFull, ownerName, aliases } = registry;
const poisoned = findPoisonedFirstNames(text, registry);
const lines = text.split('\n');
const result = [];
let inFrontmatter = false;
let frontmatterCount = 0;
let inCodeBlock = false;
for (let i = 0; i < lines.length; i++) {
let line = lines[i];
}
return result.join('\n');
}
/**
*/
function linkNamesInLine(line, fullNames, firstNameToFull, ownerName, aliases, poisoned) {
// Split line into segments: WikiLinks and non-WikiLinks
// This prevents double-linking already-linked names
const segments = line.split(/([[[^\]]+]])/);
return segments.map(seg => {
// If this segment is already a WikiLink, leave it
if (seg.startsWith('[[') && seg.endsWith(']]')) return seg;
}).join('');
}
/**
*/
function processFile(filePath, dryRun = false) {
const abs = path.resolve(filePath);
if (!fs.existsSync(abs)) {
console.error(
File not found: ${abs});return { file: abs, changes: 0 };
}
const original = fs.readFileSync(abs, 'utf8');
const registry = buildRegistry();
const linked = autoLinkContent(original, registry);
if (original === linked) {
return { file: abs, changes: 0 };
}
// Count changes
const origLinks = (original.match(/[[/g) || []).length;
const newLinks = (linked.match(/[[/g) || []).length;
const changes = newLinks - origLinks;
if (!dryRun) {
fs.writeFileSync(abs, linked, 'utf8');
}
return { file: abs, changes, dryRun };
}
/**
*/
function processToday(dryRun = false) {
const today = new Date().toISOString().slice(0, 10);
const candidates = [
path.join(VAULT_ROOT, '03-Tasks', 'Tasks.md'),
path.join(VAULT_ROOT, '02-Week_Priorities', 'Week_Priorities.md'),
];
// Add today's meetings
const meetingsDir = path.join(VAULT_ROOT, '00-Inbox', 'Meetings');
if (fs.existsSync(meetingsDir)) {
for (const file of fs.readdirSync(meetingsDir)) {
if (file.startsWith(today) && file.endsWith('.md')) {
candidates.push(path.join(meetingsDir, file));
}
}
}
// Add today's journal
const journalPath = path.join(VAULT_ROOT, 'Journal',
${today}.md);if (fs.existsSync(journalPath)) candidates.push(journalPath);
// Add today's daily plan if it exists in any common location
const dailyPlanDir = path.join(VAULT_ROOT, '02-Week_Priorities');
if (fs.existsSync(dailyPlanDir)) {
for (const file of fs.readdirSync(dailyPlanDir)) {
if (file.includes(today) && file.endsWith('.md')) {
candidates.push(path.join(dailyPlanDir, file));
}
}
}
const results = [];
const registry = buildRegistry();
for (const filePath of candidates) {
if (!fs.existsSync(filePath)) continue;
const original = fs.readFileSync(filePath, 'utf8');
const linked = autoLinkContent(original, registry);
if (original !== linked) {
const origLinks = (original.match(/[[/g) || []).length;
const newLinks = (linked.match(/[[/g) || []).length;
const changes = newLinks - origLinks;
if (!dryRun) fs.writeFileSync(filePath, linked, 'utf8');
results.push({ file: filePath, changes, dryRun });
}
}
return results;
}
// --- CLI ---
if (require.main === module) {
const args = process.argv.slice(2);
const dryRun = args.includes('--dry-run');
const filtered = args.filter(a => a !== '--dry-run');
if (filtered.includes('--today')) {
const results = processToday(dryRun);
if (results.length === 0) {
console.log('No changes needed in today's files.');
} else {
for (const r of results) {
const label = r.dryRun ? '[dry-run] ' : '';
console.log(
${label}${path.relative(VAULT_ROOT, r.file)}: +${r.changes} links);}
console.log(
\nTotal: ${results.length} files, +${results.reduce((s, r) => s + r.changes, 0)} links);}
} else if (filtered.length > 0) {
for (const filePath of filtered) {
const r = processFile(filePath, dryRun);
if (r.changes === 0) {
console.log(
${path.relative(VAULT_ROOT, r.file)}: no changes);} else {
const label = r.dryRun ? '[dry-run] ' : '';
console.log(
${label}${path.relative(VAULT_ROOT, r.file)}: +${r.changes} links);}
}
} else {
console.log('Usage:');
console.log(' node auto-link-people.cjs Process a single file');
console.log(' node auto-link-people.cjs --today Process today's key files');
console.log(' node auto-link-people.cjs --dry-run Preview without writing');
}
}
module.exports = { autoLinkContent, buildRegistry, processFile, processToday };
``