diff --git a/.claude/hooks/tests/adapter-runner.test.cjs b/.claude/hooks/tests/adapter-runner.test.cjs index 5a15fa6c..04eb2b50 100644 --- a/.claude/hooks/tests/adapter-runner.test.cjs +++ b/.claude/hooks/tests/adapter-runner.test.cjs @@ -71,7 +71,7 @@ global.fetch = async (url) => { config: { api_key: apiKey, token }, args: null, }, { - env: { ...process.env, NODE_OPTIONS: `--require=${preload}` }, + env: { ...process.env, NODE_OPTIONS: `--require="${preload}"` }, }); const output = parseOnlyJsonLine(result); const serialized = `${result.stdout}${result.stderr}`; diff --git a/.claude/hooks/tests/auto-link-people.test.cjs b/.claude/hooks/tests/auto-link-people.test.cjs index b88399e1..c6799e1e 100644 --- a/.claude/hooks/tests/auto-link-people.test.cjs +++ b/.claude/hooks/tests/auto-link-people.test.cjs @@ -100,17 +100,17 @@ test('uses the shared path contract without raw vault paths', () => { ); }); -test('links a full name to its person page', () => { +test('links a full name to its bare page name, never a file path', () => { const { autoLinkContent } = loadScript(); const registry = makeRegistry({ fullNames: ['Sarah Chen'], firstNames: [['Sarah', 'Sarah Chen']], - targets: [['Sarah Chen', '05-Areas/People/External/Sarah_Chen']], + targets: [['Sarah Chen', 'Sarah_Chen']], }); assert.equal( autoLinkContent('Met Sarah Chen today.', registry), - 'Met [[05-Areas/People/External/Sarah_Chen|Sarah Chen]] today.', + 'Met [[Sarah_Chen|Sarah Chen]] today.', ); }); @@ -120,12 +120,12 @@ test('links an unambiguous alias with its visible text preserved', () => { fullNames: ['Grace Brown'], firstNames: [['Grace', 'Grace Brown']], aliases: [['Alex', 'Grace Brown']], - targets: [['Grace Brown', '05-Areas/People/External/Grace_Brown']], + targets: [['Grace Brown', 'Grace_Brown']], }); assert.equal( autoLinkContent('Alex raised the risk.', registry), - '[[05-Areas/People/External/Grace_Brown|Alex]] raised the risk.', + '[[Grace_Brown|Alex]] raised the risk.', ); }); @@ -174,7 +174,7 @@ test('does not poison a first name from a known multi-part full name', () => { assert.equal( autoLinkContent('Sarah spoke. Later Sarah Jane Chen joined.', registry), - '[[Sarah Jane Chen|Sarah]] spoke. Later Sarah Jane Chen joined.', + '[[Sarah Jane Chen|Sarah]] spoke. Later [[Sarah Jane Chen]] joined.', ); }); @@ -221,7 +221,7 @@ test('applies the complete stoplist case-sensitively', () => { ); }); -test('leaves protected Markdown untouched and links the first prose occurrence', () => { +test('leaves protected Markdown untouched and links the prose occurrence', () => { const { autoLinkContent } = loadScript(); const registry = makeRegistry({ fullNames: ['Sarah Chen'], @@ -250,7 +250,7 @@ test('leaves protected Markdown untouched and links the first prose occurrence', assert.equal(autoLinkContent(input, registry), expected); }); -test('an existing wiki-link is untouched and consumes the person link for the file', () => { +test('an existing wiki-link is untouched and later plain mentions still get linked', () => { const { autoLinkContent } = loadScript(); const registry = makeRegistry({ fullNames: ['Sarah Chen'], @@ -258,28 +258,31 @@ test('an existing wiki-link is untouched and consumes the person link for the fi }); const input = 'Already [[Sarah Chen|Sarah]]. Later Sarah Chen spoke.'; - assert.equal(autoLinkContent(input, registry), input); + assert.equal( + autoLinkContent(input, registry), + 'Already [[Sarah Chen|Sarah]]. Later [[Sarah Chen]] spoke.', + ); }); -test('uses an Internal person path and consumes existing links by path basename or alias label', () => { +test('links every mention by bare page name and stays idempotent', () => { const { autoLinkContent } = loadScript(); const registry = makeRegistry({ fullNames: ['Morgan Reed'], firstNames: [['Morgan', 'Morgan Reed']], aliases: [['Mo', 'Morgan Reed']], - targets: [['Morgan Reed', '05-Areas/People/Internal/Morgan_Reed']], + targets: [['Morgan Reed', 'Morgan_Reed']], }); const input = 'Morgan Reed joined. Morgan followed up.'; const once = autoLinkContent(input, registry); assert.equal( once, - '[[05-Areas/People/Internal/Morgan_Reed|Morgan Reed]] joined. Morgan followed up.', + '[[Morgan_Reed|Morgan Reed]] joined. [[Morgan_Reed|Morgan]] followed up.', ); assert.equal(autoLinkContent(once, registry), once); assert.equal( autoLinkContent('Already [[elsewhere/person|mo]]. Later Morgan Reed spoke.', registry), - 'Already [[elsewhere/person|mo]]. Later Morgan Reed spoke.', + 'Already [[elsewhere/person|mo]]. Later [[Morgan_Reed|Morgan Reed]] spoke.', ); }); @@ -298,7 +301,7 @@ test('never links the owner by full name, first name, or alias', () => { ); }); -test('links only the earliest eligible occurrence for a person', () => { +test('links every eligible occurrence for a person, not just the first', () => { const { autoLinkContent } = loadScript(); const registry = makeRegistry({ fullNames: ['Sarah Chen'], @@ -307,7 +310,7 @@ test('links only the earliest eligible occurrence for a person', () => { assert.equal( autoLinkContent('Sarah spoke before Sarah Chen replied.', registry), - '[[Sarah Chen|Sarah]] spoke before Sarah Chen replied.', + '[[Sarah Chen|Sarah]] spoke before [[Sarah Chen]] replied.', ); }); @@ -320,7 +323,7 @@ test('is idempotent and preserves CRLF bytes outside the inserted link', () => { const input = 'Sarah Chen spoke.\r\nSarah followed up.\r\n'; const once = autoLinkContent(input, registry); - assert.equal(once, '[[Sarah Chen]] spoke.\r\nSarah followed up.\r\n'); + assert.equal(once, '[[Sarah Chen]] spoke.\r\n[[Sarah Chen|Sarah]] followed up.\r\n'); assert.equal(autoLinkContent(once, registry), once); }); @@ -374,10 +377,7 @@ test('buildRegistry scans every nested People directory and rejects ambiguous al assert.equal(registry.aliases.get('Lex'), 'Alex Smith'); assert.equal(registry.aliases.has('Saz'), false); assert.equal(registry.ownerName, 'Test User'); - assert.equal( - registry.targetsByFullName.get('Sarah Chen'), - '05-Areas/People/Community/Founders/Sarah_Chen', - ); + assert.equal(registry.targetsByFullName.get('Sarah Chen'), 'Sarah_Chen'); }); test('dry-run prints proposed links and writes nothing', (t) => { @@ -392,10 +392,8 @@ test('dry-run prints proposed links and writes nothing', (t) => { assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`); assert.equal(fs.readFileSync(notePath, 'utf-8'), original); assert.match(result.stdout, /\[dry-run\]/); - assert.match( - result.stdout, - /\[\[05-Areas\/People\/Community\/Sarah_Chen\|Sarah Chen\]\]/, - ); + assert.match(result.stdout, /\[\[Sarah_Chen\|Sarah Chen\]\]/); + assert.match(result.stdout, /\[\[Sarah_Chen\|Sarah\]\]/); }); test('--today processes only notes in today\'s nested meeting folder', (t) => { @@ -418,7 +416,7 @@ test('--today processes only notes in today\'s nested meeting folder', (t) => { assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`); assert.equal( fs.readFileSync(todayNote, 'utf-8'), - '[[05-Areas/People/Community/Sarah_Chen|Sarah Chen]] joined.\n', + '[[Sarah_Chen|Sarah Chen]] joined.\n', ); assert.equal(fs.readFileSync(otherNote, 'utf-8'), 'Sarah Chen joined.\n'); assert.equal(fs.readFileSync(prefixedButNotToday, 'utf-8'), 'Sarah Chen joined.\n'); diff --git a/.claude/hooks/tests/vault-autocommit.test.cjs b/.claude/hooks/tests/vault-autocommit.test.cjs index ea5222b2..b3d35283 100644 --- a/.claude/hooks/tests/vault-autocommit.test.cjs +++ b/.claude/hooks/tests/vault-autocommit.test.cjs @@ -18,7 +18,15 @@ const CONTRACT_PATH = path.join( ); function git(root, ...args) { - const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' }); + // -c safe.bareRepository=all: these tests create their own throwaway bare + // remote; without the override the helper cannot operate on it when the + // developer's git sets the security default safe.bareRepository=explicit. + // Scoped to this helper's invocations only — never touches real git config. + const result = spawnSync( + 'git', + ['-C', root, '-c', 'safe.bareRepository=all', ...args], + { encoding: 'utf8' }, + ); assert.equal(result.status, 0, `${args.join(' ')}\n${result.stdout}\n${result.stderr}`); return result.stdout.trim(); } diff --git a/.distignore b/.distignore index 8175e757..f37dfadd 100644 --- a/.distignore +++ b/.distignore @@ -41,5 +41,12 @@ scripts/ # Issue tracking (worktree artifacts) .ao-issue.md +# Personal or machine-local files that forks commonly track (never ship) +.codex/ +.obsidianignore +_import/ +bun.lock +memory/ + # This file itself .distignore diff --git a/.gitattributes b/.gitattributes index a93bbfd6..4daac19c 100644 --- a/.gitattributes +++ b/.gitattributes @@ -23,6 +23,11 @@ DISTRIBUTION_READY.md export-ignore .ao-issue.md export-ignore .distignore export-ignore .gitattributes export-ignore +.codex/ export-ignore +.obsidianignore export-ignore +_import/ export-ignore +bun.lock export-ignore +memory/ export-ignore # The historical catalog blob contained a NUL sentinel. Force the one cleanup # diff to remain reviewable as text; subsequent blobs are ordinary UTF-8. diff --git a/.scripts/auto-link-people.cjs b/.scripts/auto-link-people.cjs index 2143dcdd..ed67d372 100644 --- a/.scripts/auto-link-people.cjs +++ b/.scripts/auto-link-people.cjs @@ -139,12 +139,10 @@ function buildRegistry(pathConfig = loadPaths()) { if (!fullName) continue; fullNames.add(fullName); - if (pathConfig.VAULT_ROOT) { - const relativePath = path.relative(pathConfig.VAULT_ROOT, filePath) - .split(path.sep) - .join('/'); - targetsByFullName.set(fullName, relativePath.slice(0, -path.extname(relativePath).length)); - } + // Link targets are the bare page name (e.g. Jane_Smith), never the file + // path: Obsidian resolves bare names, and bare links survive a person + // page moving between Internal/ and External/. + targetsByFullName.set(fullName, path.basename(filePath, path.extname(filePath))); const firstName = fullName.split(/\s+/u)[0]; addTarget(firstTargets, firstName, fullName); @@ -334,7 +332,6 @@ function findMarkdownLinkEnd(text, labelOpen, labelClose, referenceLabels) { function findInlineRanges(text, blockRanges) { const ranges = []; - const wikiRanges = []; const referenceLabels = findReferenceLabels(text, blockRanges); let index = 0; @@ -348,10 +345,8 @@ function findInlineRanges(text, blockRanges) { if (text.startsWith('[[', index)) { const close = text.indexOf(']]', index + 2); if (close !== -1) { - const range = { start: index, end: close + 2, type: 'wiki' }; - ranges.push(range); - wikiRanges.push(range); - index = range.end; + ranges.push({ start: index, end: close + 2, type: 'wiki' }); + index = close + 2; continue; } } @@ -414,7 +409,7 @@ function findInlineRanges(text, blockRanges) { index += 1; } - return { ranges, wikiRanges }; + return ranges; } function mergeRanges(ranges) { @@ -461,25 +456,6 @@ function boundaryIsSafe(text, start, length) { && (!next || !WORD_CONTINUATION.test(next)); } -function canonicalWikiTarget(text, range) { - let target = text.slice(range.start + 2, range.end - 2).split('|', 1)[0].trim(); - target = target.split('#', 1)[0].trim(); - target = target.replace(/\\/g, '/'); - target = path.posix.basename(target); - if (target.toLowerCase().endsWith('.md')) target = target.slice(0, -3); - return target.replace(/_/g, ' '); -} - -function wikiLinkLabel(text, range) { - const body = text.slice(range.start + 2, range.end - 2); - const separator = body.indexOf('|'); - return separator === -1 ? '' : body.slice(separator + 1).trim(); -} - -function normalizedPersonName(name) { - return fold(name.trim().replace(/\s+/gu, ' ')); -} - function findPoisonedFirstNames(text, registry, protectedRanges) { const poisoned = new Set(); const pattern = /[\p{Lu}][\p{L}\p{M}'’\p{Pd}]*/gu; @@ -515,25 +491,8 @@ function findPoisonedFirstNames(text, registry, protectedRanges) { function autoLinkContent(text, registry = buildRegistry()) { const blockRanges = findBlockRanges(text); - const inline = findInlineRanges(text, blockRanges); - const protectedRanges = mergeRanges([...blockRanges, ...inline.ranges]); - const linkedPeople = new Set(); - const peopleByReferenceName = new Map(); - - for (const fullName of registry.fullNames) { - peopleByReferenceName.set(normalizedPersonName(fullName), fullName); - } - for (const [alias, fullName] of registry.aliases || []) { - peopleByReferenceName.set(normalizedPersonName(alias), fullName); - } - - for (const wikiRange of inline.wikiRanges) { - const target = canonicalWikiTarget(text, wikiRange); - const label = wikiLinkLabel(text, wikiRange); - const linkedPerson = peopleByReferenceName.get(normalizedPersonName(target)) - || peopleByReferenceName.get(normalizedPersonName(label)); - if (linkedPerson) linkedPeople.add(linkedPerson); - } + const inlineRanges = findInlineRanges(text, blockRanges); + const protectedRanges = mergeRanges([...blockRanges, ...inlineRanges]); const poisoned = findPoisonedFirstNames(text, registry, protectedRanges); const ownerName = registry.ownerName || ''; @@ -589,23 +548,29 @@ function autoLinkContent(text, registry = buildRegistry()) { || left.target.localeCompare(right.target) )); + // Every eligible occurrence is linked (not just the first one per person): + // notes routinely mention someone several times, and each mention should + // navigate to the person page. const replacements = []; for (const occurrence of occurrences) { - if (linkedPeople.has(occurrence.target)) continue; if (rangesOverlap(occurrence.start, occurrence.end, replacements)) continue; const linkTarget = registry.targetsByFullName?.get(occurrence.target); - const replacement = linkTarget - ? `[[${linkTarget}|${occurrence.text}]]` - : occurrence.kind === 'full' + let replacement; + if (linkTarget) { + replacement = linkTarget === occurrence.text + ? `[[${linkTarget}]]` + : `[[${linkTarget}|${occurrence.text}]]`; + } else { + replacement = occurrence.kind === 'full' ? `[[${occurrence.target}]]` : `[[${occurrence.target}|${occurrence.text}]]`; + } replacements.push({ start: occurrence.start, end: occurrence.end, replacement, }); - linkedPeople.add(occurrence.target); } let linkedText = text; diff --git a/06-Resources/Dex_System/Dex_System_Guide.md b/06-Resources/Dex_System/Dex_System_Guide.md index 803435f4..8cb4de5f 100644 --- a/06-Resources/Dex_System/Dex_System_Guide.md +++ b/06-Resources/Dex_System/Dex_System_Guide.md @@ -600,7 +600,7 @@ You control what happens next with `entity_creation` in `System/user-profile.yam - **`suggest`** — show eligible pages for your approval in `/daily-plan` and `/process-meetings`. This is the safe default when an existing vault has no setting. - **`off`** — keep tracking attendees without creating or suggesting pages. -After every sync, Dex checks that each eligible attendee and company has the expected page or suggestion. `/dex-doctor` includes the same entity-engine check when you want to inspect it yourself. In Obsidian mode, meeting notes also link names to the actual person-page paths. +After every sync, Dex checks that each eligible attendee and company has the expected page or suggestion. `/dex-doctor` includes the same entity-engine check when you want to inspect it yourself. In Obsidian mode, meeting notes also link names to their person pages. ### `/career-coach` — Auto Evidence Capture diff --git a/CLAUDE.md b/CLAUDE.md index 069df013..d98c42aa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -243,7 +243,7 @@ When the user shares meeting notes or says they had a meeting: 4. Suggest follow-ups. Use the `query` tool to search for implicit commitments — soft language like "we should revisit" or "let me think about" that regex might not catch as action items. 5. If meeting with manager and Career folder exists, extract career development context -**Automation:** Background sync records attendee emails and locations, runs entity creation, and verifies coverage after every sync; attendees without email remain tracked but are never auto-created. `/process-meetings` can still update existing pages with extracted context, while ad-hoc notes are handled manually. In Obsidian mode, `.scripts/auto-link-people.cjs` links names to the actual person-page paths. +**Automation:** Background sync records attendee emails and locations, runs entity creation, and verifies coverage after every sync; attendees without email remain tracked but are never auto-created. `/process-meetings` can still update existing pages with extracted context, while ad-hoc notes are handled manually. In Obsidian mode, `.scripts/auto-link-people.cjs` links names to their person pages. ### Task Creation (Smart Pillar Inference) When the user requests task creation without specifying a pillar: diff --git a/core/integrations/connection-manager/connection-manager.broker.test.cjs b/core/integrations/connection-manager/connection-manager.broker.test.cjs index 6e58e262..75d55989 100644 --- a/core/integrations/connection-manager/connection-manager.broker.test.cjs +++ b/core/integrations/connection-manager/connection-manager.broker.test.cjs @@ -378,7 +378,7 @@ test('accessor CLIs clearly surface presence_required with exit code 1', async ( '};', ].join('\n') ); - const env = { NODE_OPTIONS: `--require=${preload}` }; + const env = { NODE_OPTIONS: `--require="${preload}"` }; const getToken = await runCli( path.join(__dirname, 'get-token.cjs'), @@ -452,7 +452,7 @@ test('accessor CLIs preserve their contracts while calling the broker client', a ].join('\n') ); const env = { - NODE_OPTIONS: `--require=${preload}`, + NODE_OPTIONS: `--require="${preload}"`, BROKER_RECORD_FILE: recordFile, }; @@ -977,7 +977,7 @@ test('socket (verify outside sandbox): broker gates operations and accessor CLIs const result = await runCli( path.join(__dirname, 'dex-call.cjs'), [linear, 'POST', 'https://api.linear.app/graphql', '--body', '{"query":"{ viewer { id } }"}'], - { NODE_OPTIONS: `--require=${preload}` } + { NODE_OPTIONS: `--require="${preload}"` } ); assert.equal(result.status, 0, result.stderr); assert.deepEqual(JSON.parse(result.stdout), { diff --git a/core/integrations/connection-manager/connection-manager.judgment.test.cjs b/core/integrations/connection-manager/connection-manager.judgment.test.cjs index f34b301b..7ea13bb0 100644 --- a/core/integrations/connection-manager/connection-manager.judgment.test.cjs +++ b/core/integrations/connection-manager/connection-manager.judgment.test.cjs @@ -26,7 +26,7 @@ const childEnv = { DEX_CM_NO_KEYCHAIN: '1', NODE_OPTIONS: [ process.env.NODE_OPTIONS, - `--require=${path.join(DIR, 'presence-approve-preload.test.cjs')}`, + `--require="${path.join(DIR, 'presence-approve-preload.test.cjs')}"`, ].filter(Boolean).join(' '), }; diff --git a/core/integrations/connection-manager/connection-manager.phase05.test.cjs b/core/integrations/connection-manager/connection-manager.phase05.test.cjs index 43f2d579..ba64b511 100644 --- a/core/integrations/connection-manager/connection-manager.phase05.test.cjs +++ b/core/integrations/connection-manager/connection-manager.phase05.test.cjs @@ -32,7 +32,7 @@ const childEnv = { DEX_CM_ALLOW_UNVETTED: '1', NODE_OPTIONS: [ process.env.NODE_OPTIONS, - `--require=${path.join(DIR, 'presence-approve-preload.test.cjs')}`, + `--require="${path.join(DIR, 'presence-approve-preload.test.cjs')}"`, ].filter(Boolean).join(' '), }; diff --git a/core/integrations/connection-manager/connection-manager.phase5b-security.test.cjs b/core/integrations/connection-manager/connection-manager.phase5b-security.test.cjs index 354fdcbb..21739aaa 100644 --- a/core/integrations/connection-manager/connection-manager.phase5b-security.test.cjs +++ b/core/integrations/connection-manager/connection-manager.phase5b-security.test.cjs @@ -30,7 +30,7 @@ const childEnv = { DEX_CM_NO_KEYCHAIN: '1', NODE_OPTIONS: [ process.env.NODE_OPTIONS, - `--require=${path.join(DIR, 'presence-approve-preload.test.cjs')}`, + `--require="${path.join(DIR, 'presence-approve-preload.test.cjs')}"`, ].filter(Boolean).join(' '), }; diff --git a/core/integrations/connection-manager/connection-manager.test.cjs b/core/integrations/connection-manager/connection-manager.test.cjs index 15bcba59..76d5b142 100644 --- a/core/integrations/connection-manager/connection-manager.test.cjs +++ b/core/integrations/connection-manager/connection-manager.test.cjs @@ -41,7 +41,7 @@ const childEnv = { DEX_VAULT: TMP_VAULT, DEX_CM_NO_KEYCHAIN: '1', DEX_CM_ALLOW_UNVETTED: '1', - NODE_OPTIONS: [process.env.NODE_OPTIONS, `--require=${PRESENCE_PRELOAD}`].filter(Boolean).join(' '), + NODE_OPTIONS: [process.env.NODE_OPTIONS, `--require="${PRESENCE_PRELOAD}"`].filter(Boolean).join(' '), }; // Pick a real paste-a-key provider for the Class-B path. Prefer one that needs ONLY an diff --git a/core/integrations/connection-manager/connections-contract.test.cjs b/core/integrations/connection-manager/connections-contract.test.cjs index c83cf11b..5bcc4680 100644 --- a/core/integrations/connection-manager/connections-contract.test.cjs +++ b/core/integrations/connection-manager/connections-contract.test.cjs @@ -46,7 +46,7 @@ test('foreign smoke consumer uses only CLIs plus contract/schema against a scrat DEX_CM_RUNTIME_DIR: runtime, DEX_CM_BROKER_IDLE_MS: '30000', DEX_CM_NO_KEYCHAIN: '1', - NODE_OPTIONS: [process.env.NODE_OPTIONS, `--require=${PRESENCE_PRELOAD}`].filter(Boolean).join(' '), + NODE_OPTIONS: [process.env.NODE_OPTIONS, `--require="${PRESENCE_PRELOAD}"`].filter(Boolean).join(' '), }; try { execFileSync('node', [path.join(__dirname, 'connect.cjs'), 'set-key', 'linear', '--no-probe'], { @@ -80,7 +80,7 @@ test('real accessor and status CLIs conform to the published schemas and exit-co DEX_CM_RUNTIME_DIR: runtime, DEX_CM_BROKER_IDLE_MS: '30000', DEX_CM_NO_KEYCHAIN: '1', - NODE_OPTIONS: [process.env.NODE_OPTIONS, `--require=${PRESENCE_PRELOAD}`].filter(Boolean).join(' '), + NODE_OPTIONS: [process.env.NODE_OPTIONS, `--require="${PRESENCE_PRELOAD}"`].filter(Boolean).join(' '), }; const contract = JSON.parse(fs.readFileSync(CONTRACT, 'utf8')); const schema = JSON.parse(fs.readFileSync(SCHEMA, 'utf8')); diff --git a/docs/Dex_System/Dex_System_Guide.md b/docs/Dex_System/Dex_System_Guide.md index 803435f4..8cb4de5f 100644 --- a/docs/Dex_System/Dex_System_Guide.md +++ b/docs/Dex_System/Dex_System_Guide.md @@ -600,7 +600,7 @@ You control what happens next with `entity_creation` in `System/user-profile.yam - **`suggest`** — show eligible pages for your approval in `/daily-plan` and `/process-meetings`. This is the safe default when an existing vault has no setting. - **`off`** — keep tracking attendees without creating or suggesting pages. -After every sync, Dex checks that each eligible attendee and company has the expected page or suggestion. `/dex-doctor` includes the same entity-engine check when you want to inspect it yourself. In Obsidian mode, meeting notes also link names to the actual person-page paths. +After every sync, Dex checks that each eligible attendee and company has the expected page or suggestion. `/dex-doctor` includes the same entity-engine check when you want to inspect it yourself. In Obsidian mode, meeting notes also link names to their person pages. ### `/career-coach` — Auto Evidence Capture