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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ All notable changes to PBrain will be documented in this file.

## [Unreleased]

- **`pbrain doctor` is quiet when there's nothing to fix.** Three more false-positive classes eliminated: (1) the `skill_symlinks` check no longer dumps "shadowed by other plugins: claude:..., cursor:..., windsurf:..." for all 78 entries when your installed symlinks happen to point at a sibling PBrain checkout (e.g. `~/.pbrain-repo` while you're running doctor from a dev clone) — a new `ours-elsewhere` state detects any pbrain tree via `package.json.name === 'pbrain'` and the message now reads `installed — claude: 26, cursor: 26, windsurf: 26 (symlinks resolve to sibling checkout at <path>)`. (2) The `resolver_health` MECE overlap between `maintain` and `citation-fixer` on the trigger `"citation audit"` is gone — removed from `maintain` since `citation-fixer` is the specialized skill for that phrase. (3) Seven skills (`ingest`, `enrich`, `setup`, `signal-detector`, `idea-ingest`, `media-ingest`, `meeting-ingestion`) now reference `skills/conventions/quality.md` alongside their local citation / Iron-Law recap, silencing the DRY-violation warnings while keeping the inline guidance readable. Doctor's health score on a fresh install now reflects only real, user-actionable issues.
- **`pbrain doctor` no longer cries wolf on PGLite or on project/repo pairs that share a name.** The `DUPLICATE_SLUG` check was firing every time you onboarded a project whose repo name matches its product name — `projects/pbrain` + `repos/joedanz/pbrain` always collide on the tail "pbrain," even when every wikilink in the brain is path-qualified and there's no real ambiguity. It now fires only when a bare-slug `[[tail]]` wikilink actually references the ambiguous tail, and the message points at the exact page referencing it so you can fix it in one edit. Separately, the `pgvector` and `rls` checks stop reporting "Could not check ..." for PGLite users — pgvector is bundled with the PGLite WASM runtime and RLS is meaningless for a local embedded DB, so both now report green with an explanatory message. Your doctor health score now reflects real problems, not check-runner noise.
- **`project-onboard` accepts the product name and domain as positional arguments in any order, and infers the repo from your current directory.** Call it with `project-onboard <name> <domain>` from inside a repo and that's all you need — no repo URL, no keyword wrapping. Args are classified by shape, not position: anything with a slash is the repo, anything with a TLD is the domain, everything else is the display name. The project slug resolves in priority order — explicit display name wins, then domain root, then `package.json`, then the repo name with suffixes like `-web` / `-app` / `-monorepo` stripped — so a repo called `<name>-web` still gets filed under `projects/<name>`. Use `project=<value>` as a named escape hatch when a display name itself looks like a domain.
- **Onboard a coding project once, and every future Claude Code session in that project already knows it.** The `project-onboard` skill now installs a short `## pbrain` declaration into the project's own `CLAUDE.md` at the end of its run — slug, brain-query guidance, and the `pbrain remember` recipe. Every subsequent session in that project auto-recognizes it and routes brain lookups to the right slug; the skill's new Phase 0 idempotency gate short-circuits in ~5ms so re-invocation is a free no-op. No machine-wide hook, no `~/.claude/settings.json` edit, no manually-written `.pbrain-project` marker — just one gesture per project, ever. Delete the `## pbrain` section to stop tracking. Per-project opt-in keeps client checkouts and scratch clones out of your brain by default.
Expand Down
1 change: 1 addition & 0 deletions skills/enrich/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ This skill guarantees:
- No stubs: every new page has meaningful content from web search or existing brain context

> **Filing rule:** Read `skills/_brain-filing-rules.md` before creating any new page.
> **Quality rules:** See `skills/conventions/quality.md` for the canonical citation format, back-link (Iron Law), and notability-gate rules. The sections below are a local recap; the convention file is the source of truth.

## Iron Law: Back-Linking (MANDATORY)

Expand Down
1 change: 1 addition & 0 deletions skills/idea-ingest/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ mutating: true
# Idea Ingest Skill

> **Filing rule:** Read `skills/_brain-filing-rules.md` before creating any new page.
> **Quality rules:** See `skills/conventions/quality.md` for the canonical citation format, back-link (Iron Law), and notability-gate rules. The sections below are a local recap; the convention file is the source of truth.

## Contract

Expand Down
1 change: 1 addition & 0 deletions skills/ingest/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ mutating: true
Ingest meetings, articles, media, documents, and conversations into the brain.

> **Filing rule:** Read `skills/_brain-filing-rules.md` before creating any new page.
> **Quality rules:** See `skills/conventions/quality.md` for the canonical citation format, back-link (Iron Law), and notability-gate rules. The sections below are a local recap; the convention file is the source of truth.

## Contract

Expand Down
1 change: 0 additions & 1 deletion skills/maintain/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ description: |
triggers:
- "brain health"
- "check backlinks"
- "citation audit"
- "maintenance"
- "orphan pages"
- "stale pages"
Expand Down
1 change: 1 addition & 0 deletions skills/media-ingest/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ mutating: true
Ingest video, audio, PDF, book, screenshot, and GitHub repo content into the brain.

> **Filing rule:** Read `skills/_brain-filing-rules.md` before creating any new page.
> **Quality rules:** See `skills/conventions/quality.md` for the canonical citation format, back-link (Iron Law), and notability-gate rules. The sections below are a local recap; the convention file is the source of truth.

## Contract

Expand Down
1 change: 1 addition & 0 deletions skills/meeting-ingestion/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ mutating: true
# Meeting Ingestion Skill

> **Filing rule:** Read `skills/_brain-filing-rules.md` before creating any new page.
> **Quality rules:** See `skills/conventions/quality.md` for the canonical citation format, back-link (Iron Law), and notability-gate rules. The sections below are a local recap; the convention file is the source of truth.

## Contract

Expand Down
4 changes: 2 additions & 2 deletions skills/setup/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,8 +223,8 @@ Inject the key patterns into the agent's system context or AGENTS.md:

1. **Brain-agent loop** (Section 2): read before responding, write after learning
2. **Entity detection** (Section 3): spawn on every message, capture people/companies/ideas
3. **Source attribution** (Section 7): every fact needs `[Source: ...]`
4. **Iron law back-linking** (Section 15.4): every mention links back to the entity page
3. **Source attribution** (Section 7): every fact needs `[Source: ...]` — see `skills/conventions/quality.md` for the canonical format
4. **Iron law back-linking** (Section 15.4): every mention links back to the entity page — see `skills/conventions/quality.md`

Tell the user: "The production agent guide is at docs/PBRAIN_SKILLPACK.md. It covers
the brain-agent loop, entity detection, enrichment, meeting ingestion, and cron
Expand Down
2 changes: 2 additions & 0 deletions skills/signal-detector/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ This skill guarantees:

## Iron Law: Back-Linking (MANDATORY)

> **Quality rules:** See `skills/conventions/quality.md` for the canonical citation format and back-link rules. The steps below are the local procedure; the convention file is the source of truth.

Every time this skill creates or updates a brain page that mentions a person or company:
1. Check if that person/company has a brain page
2. If yes → add a back-link FROM their page TO the page you just created/updated
Expand Down
45 changes: 42 additions & 3 deletions src/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,29 @@ export async function runDoctor(engine: BrainEngine | null, args: string[]) {
// Helpers
// ---------------------------------------------------------------------------

/**
* Walk up from a resolved symlink target looking for the repo root
* (directory with package.json name=pbrain). Used to collapse per-skill
* `resolvedTo` paths to the single checkout directory for the status message.
*/
function resolveCheckoutRoot(resolvedPath: string): string {
if (!resolvedPath) return '';
let dir = resolvedPath;
for (let i = 0; i < 12; i++) {
const pkgPath = join(dir, 'package.json');
if (existsSync(pkgPath)) {
try {
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
if (pkg && pkg.name === 'pbrain') return dir;
} catch { /* keep walking */ }
}
const parent = join(dir, '..');
if (parent === dir) break;
dir = parent;
}
return '';
}

/** Find the PBrain repo root by walking up from cwd looking for skills/RESOLVER.md */
function findRepoRoot(): string | null {
let dir = process.cwd();
Expand Down Expand Up @@ -300,12 +323,25 @@ function checkSkillSymlinks(repoRoot: string): Check {
const shadowed: string[] = [];
const partial: string[] = [];

// `ours-elsewhere` (symlink to another pbrain checkout, e.g. ~/.pbrain-repo
// when doctor runs from a dev clone) counts as installed — the skills fire
// correctly, the symlinks just don't point at this particular tree.
const elsewhereByTarget = new Map<string, string[]>();
for (const t of targets) {
const present = new Set(entries.filter(e => e.target.dir === t.dir && e.state === 'ours-ok').map(e => e.name));
const present = new Set(entries.filter(e => e.target.dir === t.dir && (e.state === 'ours-ok' || e.state === 'ours-elsewhere')).map(e => e.name));
const targetBroken = entries.filter(e => e.target.dir === t.dir && e.state === 'ours-broken');
const targetShadowed = entries.filter(e => e.target.dir === t.dir && (e.state === 'foreign-symlink' || e.state === 'foreign-file' || e.state === 'foreign-dir') && skillNames.has(e.name));
const targetElsewhere = entries.filter(e => e.target.dir === t.dir && e.state === 'ours-elsewhere');
for (const e of targetBroken) broken.push(`${t.client}:${e.name}`);
for (const e of targetShadowed) shadowed.push(`${t.client}:${e.name}`);
if (targetElsewhere.length > 0) {
const checkouts = [...new Set(targetElsewhere.map(e => resolveCheckoutRoot(e.resolvedTo || '')).filter(Boolean))];
for (const root of checkouts) {
const list = elsewhereByTarget.get(root) || [];
list.push(`${t.client} ${targetElsewhere.length}`);
elsewhereByTarget.set(root, list);
}
}
if (present.size > 0 && present.size < skills.length) {
partial.push(`${t.client} ${present.size}/${skills.length}`);
}
Expand All @@ -316,7 +352,7 @@ function checkSkillSymlinks(repoRoot: string): Check {
const installedByClient = targets
.map(t => ({
client: t.client,
count: entries.filter(e => e.target.dir === t.dir && e.state === 'ours-ok').length,
count: entries.filter(e => e.target.dir === t.dir && (e.state === 'ours-ok' || e.state === 'ours-elsewhere')).length,
}))
.filter(x => x.count > 0);
if (installedByClient.length === 0) {
Expand All @@ -327,10 +363,13 @@ function checkSkillSymlinks(repoRoot: string): Check {
};
}
const summary = installedByClient.map(x => `${x.client}: ${x.count}`).join(', ');
const elsewhereNote = elsewhereByTarget.size > 0
? ` (symlinks resolve to sibling checkout at ${[...elsewhereByTarget.keys()].join(', ')})`
: '';
return {
name: 'skill_symlinks',
status: 'ok',
message: `installed — ${summary}`,
message: `installed — ${summary}${elsewhereNote}`,
};
}

Expand Down
35 changes: 34 additions & 1 deletion src/core/skill-installer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,11 +268,42 @@ export interface StatusEntry {
/** Skill name at that dir. */
name: string;
dst: string;
state: 'ours-ok' | 'ours-broken' | 'foreign-symlink' | 'foreign-file' | 'foreign-dir';
/**
* - `ours-ok`: symlink resolves into the current repo root.
* - `ours-broken`: symlink was clearly ours but now dangles.
* - `ours-elsewhere`: symlink resolves into a *different* PBrain checkout
* (e.g., `~/.pbrain-repo` when doctor is run from a dev clone). Still a
* valid install — just not pointing at this tree.
* - `foreign-*`: a real other-plugin file / symlink / directory.
*/
state: 'ours-ok' | 'ours-broken' | 'ours-elsewhere' | 'foreign-symlink' | 'foreign-file' | 'foreign-dir';
/** Where the symlink points (resolved). */
resolvedTo?: string;
}

/**
* Walk up from `pathInside` looking for a `package.json` whose `name` field
* is `pbrain`. Used to classify symlinks that land in a different PBrain
* checkout (e.g. the global `~/.pbrain-repo` install) as `ours-elsewhere`
* rather than `foreign-symlink` — they're valid installs, just not this tree.
*/
function isPbrainCheckout(pathInside: string): boolean {
let dir = pathInside;
for (let i = 0; i < 12; i++) {
const pkgPath = join(dir, 'package.json');
if (existsSync(pkgPath)) {
try {
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
if (pkg && pkg.name === 'pbrain') return true;
} catch { /* malformed — keep walking */ }
}
const parent = dirname(dir);
if (parent === dir) break;
dir = parent;
}
return false;
}

/**
* Inspect every entry under each target dir and classify it. Used by
* `install-skills status` and by the doctor check.
Expand Down Expand Up @@ -321,6 +352,8 @@ export function scanTargets(targets: Target[], repoRoot: string): StatusEntry[]
}
if (rootList.some(r => pathInsideRoot(resolved!, r))) {
entries.push({ target, name, dst, state: 'ours-ok', resolvedTo: resolved });
} else if (isPbrainCheckout(resolved!)) {
entries.push({ target, name, dst, state: 'ours-elsewhere', resolvedTo: resolved });
} else {
entries.push({ target, name, dst, state: 'foreign-symlink', resolvedTo: resolved });
}
Expand Down
40 changes: 40 additions & 0 deletions test/install-skills.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,46 @@ describe('scanTargets', () => {
const entries = scanTargets([{ client: 'claude', dir: targetDir }], repoRoot);
expect(entries[0].state).toBe('ours-broken');
});

test('symlink into a sibling pbrain checkout is classified as ours-elsewhere', () => {
// Two pbrain checkouts: repoRoot (where doctor is running) and siblingRoot
// (where the installed symlinks actually point — e.g. ~/.pbrain-repo).
const repoRoot = join(scratchDir, 'dev-clone');
const siblingRoot = join(scratchDir, 'sibling-pbrain');
mkdirSync(join(repoRoot, 'skills', 'foo'), { recursive: true });
writeFileSync(join(repoRoot, 'skills', 'foo', 'SKILL.md'), '---\nname: foo\n---');
writeFileSync(join(repoRoot, 'package.json'), JSON.stringify({ name: 'pbrain' }));
mkdirSync(join(siblingRoot, 'skills', 'foo'), { recursive: true });
const siblingSkill = join(siblingRoot, 'skills', 'foo', 'SKILL.md');
writeFileSync(siblingSkill, '---\nname: foo\n---');
writeFileSync(join(siblingRoot, 'package.json'), JSON.stringify({ name: 'pbrain' }));

const targetDir = join(scratchDir, 'client2', 'skills');
mkdirSync(targetDir, { recursive: true });
symlinkSync(siblingSkill, join(targetDir, 'foo'));

const entries = scanTargets([{ client: 'claude', dir: targetDir }], repoRoot);
expect(entries[0].state).toBe('ours-elsewhere');
expect(entries[0].resolvedTo).toContain('sibling-pbrain');
});

test('symlink into a non-pbrain foreign project stays foreign-symlink', () => {
const repoRoot = join(scratchDir, 'r1');
mkdirSync(repoRoot, { recursive: true });
writeFileSync(join(repoRoot, 'package.json'), JSON.stringify({ name: 'pbrain' }));
const foreignRoot = join(scratchDir, 'not-pbrain');
mkdirSync(join(foreignRoot, 'skills', 'foo'), { recursive: true });
const foreignSkill = join(foreignRoot, 'skills', 'foo', 'SKILL.md');
writeFileSync(foreignSkill, 'x');
writeFileSync(join(foreignRoot, 'package.json'), JSON.stringify({ name: 'something-else' }));

const targetDir = join(scratchDir, 'client3', 'skills');
mkdirSync(targetDir, { recursive: true });
symlinkSync(foreignSkill, join(targetDir, 'foo'));

const entries = scanTargets([{ client: 'claude', dir: targetDir }], repoRoot);
expect(entries[0].state).toBe('foreign-symlink');
});
});

describe('detectClients', () => {
Expand Down
Loading