Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/hooks/tests/adapter-runner.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
Expand Down
48 changes: 23 additions & 25 deletions .claude/hooks/tests/auto-link-people.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
);
});

Expand All @@ -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.',
);
});

Expand Down Expand Up @@ -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.',
);
});

Expand Down Expand Up @@ -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'],
Expand Down Expand Up @@ -250,36 +250,39 @@ 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'],
firstNames: [['Sarah', 'Sarah Chen']],
});
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.',
);
});

Expand All @@ -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'],
Expand All @@ -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.',
);
});

Expand All @@ -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);
});

Expand Down Expand Up @@ -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) => {
Expand All @@ -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) => {
Expand All @@ -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');
Expand Down
10 changes: 9 additions & 1 deletion .claude/hooks/tests/vault-autocommit.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
7 changes: 7 additions & 0 deletions .distignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 5 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
75 changes: 20 additions & 55 deletions .scripts/auto-link-people.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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;

Expand All @@ -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;
}
}
Expand Down Expand Up @@ -414,7 +409,7 @@ function findInlineRanges(text, blockRanges) {
index += 1;
}

return { ranges, wikiRanges };
return ranges;
}

function mergeRanges(ranges) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 || '';
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion 06-Resources/Dex_System/Dex_System_Guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down Expand Up @@ -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,
};

Expand Down Expand Up @@ -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), {
Expand Down
Loading
Loading